Merge branch 'feature/dds' into merge/dds-into-dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

Brings the DDS (Daily Deposit Scheme) backend from feature/dds onto the
current dev-latest:
- Agent settlement (Agent Tran), Close Party account, reports
  (Supplementary-DDS, Master Data Export, Close Register), Party-Savings
  link, savings product category field
- Collection entries held before posting, per-agent GL accounts,
  maturity auto close
- Vouchers, maker-checker approvals, payout & close, Sundry GL, closure
  tab and fixes
- POST_DDS_INTEREST job

No content conflicts. No dev-latest file is removed; only three
dev-latest lines are extended with a DDS condition:
- CommandSourceService: DDS entities join ROLE in the maker-checker
  entity check (isDdsEntity).
- PortfolioCommandSourceWritePlatformServiceImpl: a DDS entry can never
  be approved by the user who made it, even a checker super user. The
  rule for every other entity is unchanged.
- SavingsProductDataValidator: productCategory accepted as a savings
  product parameter.

Files changed on both branches and auto-merged: CommandWrapperBuilder,
CommandSourceService, JobName, SavingsApiConstants, SavingsProductData,
SavingsProduct, FixedDepositProductData, RecurringDepositProductData,
DepositProductReadPlatformServiceImpl. Checked for duplicate methods,
fields and enum names.

Liquibase: 55 DDS changelogs (0470-0525) included as one block at the
end of changelog-tenant.xml. Their numbers overlap dev-latest's files
but no file name is shared, and Liquibase tracks changesets by file
path, so nothing clashes. They keep their names so databases that
already ran them do not run them again. No permission code, job name
or configuration name is inserted by both branches.

Verified with :fineract-provider:compileJava. The full compileJava
fails only in fineract-client (generated CKYC, committee meeting and
election APIs), which feature/dds does not touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:11:14 +05:30
co-authored by Claude Opus 5.5
228 changed files with 23739 additions and 3 deletions
@@ -0,0 +1,120 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/agents")
@Component
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
@RequiredArgsConstructor
public class AgentsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
private final PlatformSecurityContext context;
private final AgentReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
public Collection<AgentData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(agentId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate\n" + "\n"
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, officeId, guarantors\n" + "\n"
+ "officeId is the branch the agent belongs to, and defaults to the client's own office. "
+ "agentSavingAccountNo must be an open savings account of that same client - the agent's "
+ "commission is paid into it.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createAgent() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateAgent(agentId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close a DDS Agent", description = "Ends the relationship with an agent. Refused while they still have "
+ "accounts assigned, hold collected cash, or are owed commission - the response names which. Distinct from "
+ "deactivating the agent, which stops them collecting and is never refused.")
public CommandProcessingResult close(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.closeAgent(agentId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,65 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AgentRepository extends JpaRepository<Agent, Long> {
/**
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
* "long" type - the cast has to name a database type.
*/
@Query(value = "select coalesce(max(cast(substring(agent_code, 3) as bigint)), 0) from m_dds_agent "
+ "where agent_code like 'AG%'", nativeQuery = true)
Long retrieveMaxAgentCodeSuffix();
Optional<Agent> findByAgentCode(String agentCode);
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
/**
* Agents with commission earned but not yet paid - what a Commission Posting run has to pay out.
* Agents owed nothing are left out rather than previewed as zero rows.
*/
@Query("select a from Agent a where a.id between :agentFrom and :agentTo and a.commission > 0")
List<Agent> findWithCommissionOwed(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
/**
* The agent already holding this ledger account, if any.
*
* Asked before an account is handed to a second agent. The database refuses it too, but a
* constraint violation names a constraint; this names the agent, which is what the person
* choosing needs to hear.
*/
@Query("select a from Agent a where a.glAccount.id = :glAccountId")
Agent findByGlAccountId(@Param("glAccountId") Long glAccountId);
/**
* The client's agents still on the books. One person is one agent: a second would split their
* collections and commission between two codes paying into the same account.
*/
@Query("select a from Agent a where a.client.id = :clientId and a.active = true")
List<Agent> findActiveByClientId(@Param("clientId") Long clientId);
}
@@ -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);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "CREATE")
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createAgent(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "UPDATE")
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateAgent(command.entityId(), command);
}
}
@@ -0,0 +1,482 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.exception.AgentCannotGuaranteeItselfException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Validates create/update commands for an Agent - the DDS collection agent. The parameter set follows
* the legacy Agent Master Entry screen. homeBranch is deliberately absent: it is derived from the
* agent's client's office in the write service, not supplied by the caller.
*/
@Component
public final class AgentCommandFromApiJsonDeserializer {
public static final String CLIENT_ID = "clientId";
public static final String AGENT_NAME_ID = "agentNameId";
public static final String AGENT_ADDRESS1 = "agentAddress1";
public static final String AGENT_ADDRESS2 = "agentAddress2";
public static final String AGENT_CITY = "agentCity";
public static final String AGENT_PINCODE = "agentPinCode";
public static final String AGENT_TELEPHONE = "agentTelephone";
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
/**
* The ages the bank takes agents on at: at least 16, and not yet 60. Kept as named constants
* rather than written into the check, because they are policy numbers the bank may one day want
* to read off, not an arithmetic detail.
*/
private static final int MINIMUM_AGENT_AGE_YEARS = 16;
private static final int MAXIMUM_AGENT_AGE_YEARS = 60;
/**
* Which branch the agent works out of, as an office id. Optional: it defaults to the client's
* own office, and is only sent when the agent collects out of a different branch from the one
* their client record sits in. The DDS batch runs filter by it.
*
* The branch name is deliberately not accepted - it is derived from the office the id names, so
* a caller cannot file an agent under a branch that does not exist, or under a name that
* disagrees with the office actually stored.
*/
public static final String OFFICE_ID = "officeId";
/**
* The ledger account this agent's collections pass through. Required, and one agent to an
* account: what is left standing on it is the cash this agent is holding and has not brought in,
* which is a question nobody could answer while every agent shared one account.
*
* The agents created before this existed carry none, and go on posting to the shared account
* until somebody opens them and chooses - the rule is enforced on what is written from here on,
* not applied backwards over records nobody has looked at.
*/
public static final String GL_ACCOUNT_ID = "glAccountId";
public static final String ACTIVE = "active";
public static final String GUARANTORS = "guarantors";
public static final String DATE_FORMAT = "dateFormat";
public static final String LOCALE = "locale";
/**
* Commission settings and the standing deductions the commission run applies. Update-only: an
* agent is created earning full commission with nothing deducted, and these are set afterwards
* when the bank decides otherwise. The running totals the batch maintains - the commission owed
* and the TDS withheld - are not parameters at all; they are outcomes of posted runs.
*/
public static final String COMMISSION_CALCULATION = "commissionCalculation";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_PERCENT = "tdsPercent";
public static final String DEDUCT_COMMISSION = "deductCommission";
public static final String AGENT_DEPOSIT = "agentDeposit";
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
public static final String OTHER_DEDUCTION = "otherDeduction";
private static final String GUARANTOR_CLIENT_ID = "clientId";
private static final String GUARANTOR_NAME = "guarantorName";
private static final String GUARANTOR_ADDRESS1 = "address1";
private static final String GUARANTOR_ADDRESS2 = "address2";
private static final String GUARANTOR_CITY = "city";
private static final String GUARANTOR_PINCODE = "pinCode";
private static final String GUARANTOR_TELEPHONE = "telephone";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, OFFICE_ID, GL_ACCOUNT_ID, ACTIVE, GUARANTORS, DATE_FORMAT,
LOCALE));
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(SUPPORTED_PARAMETERS);
static {
SUPPORTED_UPDATE_PARAMETERS.addAll(Arrays.asList(COMMISSION_CALCULATION, TDS_APPLICABLE, TDS_PERCENT, DEDUCT_COMMISSION,
AGENT_DEPOSIT, FESTIVAL_ADVANCE, OTHER_DEDUCTION));
}
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
/*
* What a phone number and a pincode have to look like.
*
* Length alone was all these carried, so either could be typed as letters and nothing would say
* so until somebody tried to ring the agent or post to them. Kept in step with the Party screen,
* which asks for the same two things of the customer.
*/
private static final Pattern TELEPHONE_PATTERN = Pattern.compile("^[0-9+()\\-\\s]{6,20}$");
private static final Pattern PINCODE_PATTERN = Pattern.compile("^[1-9][0-9]{5}$");
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
"Pincode must be 6 digits and cannot start with 0");
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
"Telephone number may contain only digits, spaces and + - ( )");
}
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
// Optional: the write service falls back to the client's own office. Only checked for shape
// here - that the office exists is the write service's business.
if (this.fromApiJsonHelper.parameterExists(OFFICE_ID, element)) {
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).ignoreIfNull().integerGreaterThanZero();
}
// Required, because an agent without one of their own falls back to the account every other
// agent shares - and a balance sitting on that account says money is outstanding without
// saying whose, which is the whole thing this field exists to stop.
//
// Shape only. Whether the account exists, may carry entries, and is not already another
// agent's is the write service's business - it has the ledger and the other agents to hand.
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
validateAgentBirthDate(agentBirthDate, baseDataValidator, true);
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
"Pincode must be 6 digits and cannot start with 0");
}
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
"Telephone number may contain only digits, spaces and + - ( )");
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
validateAgentBirthDate(agentBirthDate, baseDataValidator, false);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
// Sent means chosen: an update may leave the field out and keep what the agent has, but it
// may not send it empty. Clearing it would put the agent back on the shared account, which
// is the state this field was added to get out of.
if (this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
}
// Only ever sent on its own, by the list's activate/deactivate action - an agent is always
// created active, so this is an update-time parameter.
if (this.fromApiJsonHelper.parameterExists(ACTIVE, element)) {
final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ACTIVE, element);
baseDataValidator.reset().parameter(ACTIVE).value(active).notNull();
}
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
final Boolean commissionCalculation = this.fromApiJsonHelper.extractBooleanNamed(COMMISSION_CALCULATION, element);
baseDataValidator.reset().parameter(COMMISSION_CALCULATION).value(commissionCalculation).notNull();
}
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
final Boolean tdsApplicable = this.fromApiJsonHelper.extractBooleanNamed(TDS_APPLICABLE, element);
baseDataValidator.reset().parameter(TDS_APPLICABLE).value(tdsApplicable).notNull();
}
// A tax rate above 100% would take more than the commission it is levied on.
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
final var tdsPercent = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element);
baseDataValidator.reset().parameter(TDS_PERCENT).value(tdsPercent).ignoreIfNull().zeroOrPositiveAmount()
.notGreaterThanMax(BigDecimal.valueOf(100));
}
validateDeduction(baseDataValidator, element, DEDUCT_COMMISSION);
validateDeduction(baseDataValidator, element, AGENT_DEPOSIT);
validateDeduction(baseDataValidator, element, FESTIVAL_ADVANCE);
validateDeduction(baseDataValidator, element, OTHER_DEDUCTION);
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* A deduction is an amount withheld, so it is entered as a positive figure - a negative one would
* quietly pay the agent more rather than less.
*/
private void validateDeduction(final DataValidatorBuilder baseDataValidator, final JsonElement element, final String parameterName) {
if (this.fromApiJsonHelper.parameterExists(parameterName, element)) {
final var value = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(parameterName, element);
baseDataValidator.reset().parameter(parameterName).value(value).ignoreIfNull().zeroOrPositiveAmount();
}
}
/**
* A date of birth that is today or later describes nobody, and until now nothing stopped one
* being saved - the field was only checked for being present. The slip is easy to make, because
* the date picker opens on today and a stray Enter takes it.
*
* The age is measured against today rather than against the opening date: an agent opened years
* ago has only grown older since, so today is the reading that cannot wrongly reject a record
* that was legitimate when it was made.
*
* The age range is applied when an agent is taken on. An agent already on the books is not
* refused an edit for having turned 60 since - their age was right when they were appointed.
*/
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator,
final boolean appointing) {
if (agentBirthDate == null) {
return;
}
final LocalDate today = DateUtils.getBusinessLocalDate();
if (!agentBirthDate.isBefore(today)) {
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
.failWithCode("cannot.be.today.or.a.future.date");
return;
}
if (!appointing) {
return;
}
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
.failWithCodeAndMessage("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old",
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
} else if (!agentBirthDate.isAfter(today.minusYears(MAXIMUM_AGENT_AGE_YEARS))) {
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
.failWithCodeAndMessage("agent.must.be.under." + MAXIMUM_AGENT_AGE_YEARS + ".years.old",
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
}
}
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
final Long agentClientId = extractLong(topLevelJson, CLIENT_ID);
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
final Long guarantorClientId = extractLong(guarantorJson, GUARANTOR_CLIENT_ID);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i).value(guarantorClientId).notNull()
.integerGreaterThanZero();
// An agent cannot stand guarantor for itself - a guarantee only counts coming from
// someone else.
if (agentClientId != null && agentClientId.equals(guarantorClientId)) {
throw new AgentCannotGuaranteeItselfException(agentClientId);
}
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
.notBlank().notExceedingLengthOf(200);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
.notBlank().notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private Long extractLong(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
}
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
/**
* Checks a value against the shape its kind has to take, and says what that shape is.
*
* The message names the format rather than only rejecting the entry: "must be 6 digits" tells
* the clerk what to type next, where "invalid" leaves them guessing.
*/
private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape,
final String describedAs) {
if (org.apache.commons.lang3.StringUtils.isBlank(value)) {
return;
}
// The message is given to the validator rather than left in the args, so what the clerk
// reads is what to type next rather than that something was wrong.
v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs);
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import java.util.Collection;
import org.apache.fineract.organisation.agent.data.AgentData;
public interface AgentReadPlatformService {
Collection<AgentData> retrieveAll();
AgentData retrieveOne(Long agentId);
}
@@ -0,0 +1,175 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
private static final class AgentMapper implements RowMapper<AgentData> {
public String schema() {
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
// The branch name is read off the office itself, so renaming a branch shows
// through here rather than leaving every agent under the old name.
+ " a.office_id as officeId, coalesce(o.name, a.home_branch) as homeBranch, "
// The ledger account this agent's collections pass through, with its code and name so
// the screen can show what was chosen without a second call.
+ " a.gl_account_id as glAccountId, gl.gl_code as glAccountCode, gl.name as glAccountName, "
+ " a.commission as commission, a.tds_amount as tdsAmount, "
+ " a.commission_calculation as commissionCalculation, a.tds_applicable as tdsApplicable, "
+ " a.tds_percent as tdsPercent, a.deduct_commission as deductCommission, a.agent_deposit as agentDeposit, "
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction, "
+ " a.is_active as active, a.close_date as closeDate, "
+ " a.created_date as createdDate, a.lastmodified_date as lastModifiedDate "
+ " from m_dds_agent a join m_client c on c.id = a.client_id"
+ " left join m_office o on o.id = a.office_id"
+ " left join acc_gl_account gl on gl.id = a.gl_account_id";
}
@Override
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return AgentData.builder() //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
.clientName(rs.getString("clientName")) //
.agentCode(rs.getString("agentCode")) //
.agentNameId(rs.getString("agentNameId")) //
.agentAddress1(rs.getString("agentAddress1")) //
.agentAddress2(rs.getString("agentAddress2")) //
.agentCity(rs.getString("agentCity")) //
.agentPinCode(rs.getString("agentPinCode")) //
.agentTelephone(rs.getString("agentTelephone")) //
.agentSavingAccountType(rs.getString("agentSavingAccountType")) //
.agentSavingAccountNo(rs.getString("agentSavingAccountNo")) //
.agentOpeningDate(JdbcSupport.getLocalDate(rs, "agentOpeningDate")) //
.agentBirthDate(JdbcSupport.getLocalDate(rs, "agentBirthDate")) //
.agentCurrentBalance(rs.getBigDecimal("agentCurrentBalance")) //
.agentOpeningBalance(rs.getBigDecimal("agentOpeningBalance")) //
.officeId(JdbcSupport.getLong(rs, "officeId")) //
.glAccountId(JdbcSupport.getLong(rs, "glAccountId")) //
.glAccountCode(rs.getString("glAccountCode")) //
.glAccountName(rs.getString("glAccountName")) //
.homeBranch(rs.getString("homeBranch")) //
.commission(rs.getBigDecimal("commission")) //
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
.commissionCalculation(rs.getBoolean("commissionCalculation")) //
.tdsApplicable(rs.getBoolean("tdsApplicable")) //
.tdsPercent(rs.getBigDecimal("tdsPercent")) //
.deductCommission(rs.getBigDecimal("deductCommission")) //
.agentDeposit(rs.getBigDecimal("agentDeposit")) //
.festivalAdvance(rs.getBigDecimal("festivalAdvance")) //
.otherDeduction(rs.getBigDecimal("otherDeduction")) //
.active(rs.getBoolean("active")) //
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
.createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
.guarantors(new ArrayList<>()) //
.build();
}
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
final Timestamp timestamp = rs.getTimestamp(columnName);
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
public String schema() {
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
+ " g.telephone as telephone from m_dds_agent_guarantor g";
}
@Override
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
rs.getString("pinCode"), rs.getString("telephone"));
return Map.entry(agentId, data);
}
}
@Override
public Collection<AgentData> retrieveAll() {
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
return attachGuarantors(agents);
}
@Override
public AgentData retrieveOne(final Long agentId) {
try {
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
return attachGuarantors(List.of(agent)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new AgentNotFoundException(agentId, e);
}
}
private Collection<AgentData> attachGuarantors(final List<AgentData> agents) {
if (agents.isEmpty()) {
return agents;
}
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<AgentData> result = new ArrayList<>(agents.size());
for (final AgentData agent : agents) {
result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build());
}
return result;
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface AgentWritePlatformService {
CommandProcessingResult createAgent(JsonCommand command);
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
/**
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
* collected cash, or are owed commission - each of those would be left with nobody responsible
* for it. Distinct from deactivating, which stops an agent collecting and is never blocked.
*/
CommandProcessingResult closeAgent(Long agentId, JsonCommand command);
}
@@ -0,0 +1,404 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountClientMismatchException;
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotFoundException;
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotOperativeException;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.organisation.agent.exception.AgentGlAccountUnusableException;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final AgentRepository agentRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final OfficeRepositoryWrapper officeRepositoryWrapper;
private final GLAccountRepository glAccountRepository;
private final PartyRepository partyRepository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
public CommandProcessingResult createAgent(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
refuseIfAlreadyAnAgent(clientId, null);
validateSavingsAccount(clientId, command);
final String agentCode = generateNextAgentCode();
final Agent agent = Agent.fromJson(client, agentCode, officeOf(client, command), command);
agent.setGlAccount(glAccountOf(command, null));
addGuarantorsFromCommand(agent, command);
this.agentRepository.saveAndFlush(agent);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agent.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
// Checked against whichever client the agent will belong to after this update, not the
// one they belonged to before - otherwise moving an agent to a new client would carry the
// old client's account across unchallenged.
final Long owningClientId = command.parameterExists("clientId") ? command.longValueOfParameterNamed("clientId")
: agentForUpdate.clientId();
validateSavingsAccount(owningClientId, command);
final Map<String, Object> changesOnly = agentForUpdate.update(command);
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
refuseIfAlreadyAnAgent(newClientId, agentId);
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
agentForUpdate.changeClient(newClient);
// The branch follows the client unless the caller named one - see officeOf.
agentForUpdate.setOffice(officeOf(newClient, command));
changesOnly.put("officeId", agentForUpdate.officeId());
} else if (command.isChangeInLongParameterNamed("officeId", agentForUpdate.officeId())) {
final Long newOfficeId = command.longValueOfParameterNamed("officeId");
agentForUpdate.setOffice(this.officeRepositoryWrapper.findOneWithNotFoundDetection(newOfficeId));
changesOnly.put("officeId", newOfficeId);
}
if (command.isChangeInLongParameterNamed("glAccountId", agentForUpdate.glAccountId())) {
agentForUpdate.setGlAccount(glAccountOf(command, agentForUpdate.getId()));
changesOnly.put("glAccountId", agentForUpdate.glAccountId());
}
if (command.parameterExists("guarantors")) {
agentForUpdate.resetGuarantors();
addGuarantorsFromCommand(agentForUpdate, command);
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
}
this.agentRepository.saveAndFlush(agentForUpdate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
/**
* Ends the relationship with an agent, refusing while anything is still outstanding.
*
* The three conditions are the three things that would be left with nobody responsible for them:
* accounts still being collected from, cash the agent holds that has not reached a branch, and
* commission earned but not paid. Each is reported by name and figure, so whoever is closing the
* agent knows exactly what to settle first rather than being told only that it failed.
*
* Deactivating an agent is deliberately not guarded this way - see
* {@link org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException}.
*/
@Transactional
@Override
public CommandProcessingResult closeAgent(final Long agentId, final JsonCommand command) {
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
if (agent.isClosed()) {
throw new AgentCannotBeClosedException(agent.getAgentCode(), "it is already closed");
}
final Long openAccounts = this.partyRepository.countActivePartiesOfAgent(agentId);
if (openAccounts != null && openAccounts > 0) {
throw new AgentCannotBeClosedException(agent.getAgentCode(), openAccounts
+ (openAccounts == 1 ? " account is" : " accounts are")
+ " still assigned to them. Move them to another agent first.");
}
if (agent.holdsCash()) {
throw new AgentCannotBeClosedException(agent.getAgentCode(), "they still hold "
+ agent.getAgentCurrentBalance().toPlainString()
+ " in collected cash. Settle it to a branch first (Agent Tran).");
}
if (agent.isOwedCommission()) {
throw new AgentCannotBeClosedException(agent.getAgentCode(),
"they are still owed " + agent.getCommission().toPlainString()
+ " in commission. Pay it out first (Commission Posting).");
}
agent.close(DateUtils.getBusinessLocalDate());
this.agentRepository.saveAndFlush(agent);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).build();
}
/**
* Which branch this agent belongs to.
*
* The client's own office is the default, since that is where the person is on the bank's books.
* A caller may still name a branch, because an agent can be registered at one office and collect
* out of another - the batch runs filter by this, so it has to say where the agent actually
* works rather than where their client record happens to sit.
*
* Resolved to an actual office row rather than taken as a name: an unknown id is refused here,
* where a name that matched no branch used to be stored as happily as one that did.
*/
/**
* The ledger account this agent's collections are to pass through, or null to leave them on the
* scheme's shared one.
*
* Four things are asked of it, and each has a way of going wrong that would not show up until
* money was moving. It has to exist. It has to be a detail account, because a header account is
* a heading over others and carries no entries of its own. It has to be enabled, or every
* posting through it would be refused. And it has to belong to no other agent, since two agents
* on one account is the shared account again under a different name - the balance would say what
* is outstanding without saying whose.
*
* What it deliberately does not check is the account's classification. Cash held by an agent is
* an asset and the branch will almost always pick one, but where a bank chooses to carry it is
* the bank's own accounting policy, and this is not the place to overrule it.
*
* @param forAgentId
* the agent being changed, so an agent keeping its own account is not refused for
* already holding it. Null when the agent does not exist yet.
*/
private GLAccount glAccountOf(final JsonCommand command, final Long forAgentId) {
if (!command.parameterExists("glAccountId")) {
return null;
}
final Long glAccountId = command.longValueOfParameterNamed("glAccountId");
if (glAccountId == null) {
return null;
}
final GLAccount glAccount = this.glAccountRepository.findById(glAccountId)
.orElseThrow(() -> new AgentGlAccountUnusableException(String.valueOf(glAccountId), "no such ledger account exists"));
if (glAccount.isHeaderAccount()) {
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is a header account and carries no entries of its own");
}
if (glAccount.isDisabled()) {
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is disabled");
}
final Agent holder = this.agentRepository.findByGlAccountId(glAccountId);
if (holder != null && !holder.getId().equals(forAgentId)) {
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "agent " + holder.getAgentCode() + " already uses it");
}
return glAccount;
}
/**
* One client is one agent. A second code for the same person splits their collections, their
* commission and their cash-in-hand across two records paying into the same account, and every
* agent-wise report then shows them twice. A closed agent does not count - the person can be taken
* on again.
*/
private void refuseIfAlreadyAnAgent(final Long clientId, final Long forAgentId) {
for (final Agent existing : this.agentRepository.findActiveByClientId(clientId)) {
if (!existing.getId().equals(forAgentId)) {
throw new PlatformDataIntegrityException("error.msg.agent.client.already.agent",
"This client is already agent " + existing.getAgentCode() + ". One client can be only one agent.",
"clientId", existing.getAgentCode());
}
}
}
private Office officeOf(final Client client, final JsonCommand command) {
if (command.parameterExists("officeId")) {
final Long officeId = command.longValueOfParameterNamed("officeId");
if (officeId != null) {
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
}
}
return client.getOffice();
}
/**
* The open states of a savings account, matching SavingsAccountRepository's own "non closed" set.
* deposit_type_enum comes back too: 100 is a savings account, 200 a fixed deposit, 300 recurring.
*/
/**
* The account an agent's commission is to be paid into, and enough about it to say whether it
* can take one.
*
* Active only. An account still awaiting approval, or approved but never activated, takes no
* credit at all - and one accepted here would fail silently on every commission run from then
* on, leaving the agent unpaid with nothing on screen to say why.
*/
private static final String OPEN_ACCOUNT_SQL = "select a.client_id, p.deposit_type_enum, a.product_id from m_savings_account a "
+ "join m_savings_product p on p.id = a.product_id "
+ "where a.account_no = ? and a.status_enum = 300";
/** The product DDS opens customers' scheme accounts under - see 0492. */
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
+ "where name = 'dds-deposit-product' and enabled = true";
private static final int SAVINGS_DEPOSIT_TYPE = 100;
/**
* Refuses a savings account that does not exist, or that belongs to somebody else.
*
* The agent's commission is paid into this account. Until this ran the number was free text, and
* the live data showed what that costs: agent AG0001 carried "1234567890", which matches no
* account in the database at all - so every commission recorded against it had nowhere to go.
*
* Enforced here rather than only on the screen, because the API can be called directly.
*
* Reads the owner's id rather than loading the account entity. Loading it pulled its product in
* too, and saving the agent afterwards then failed for a fixed-deposit account with "The
* attribute [id] of class FixedDepositProduct ... Updates are not allowed" - the agent's own
* insert flushing a savings product it had merely looked at. Two columns are all this check
* needs, and reading them touches no entity the transaction can try to write back.
*/
private void validateSavingsAccount(final Long expectedClientId, final JsonCommand command) {
if (!command.parameterExists("agentSavingAccountNo")) {
return;
}
final String accountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
if (accountNo == null || accountNo.isBlank()) {
return;
}
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(OPEN_ACCOUNT_SQL, accountNo); // NOSONAR
if (accounts.isEmpty()) {
throw new AgentSavingsAccountNotFoundException(accountNo);
}
final Map<String, Object> account = accounts.get(0);
final Long ownerId = ((Number) account.get("client_id")).longValue();
if (!expectedClientId.equals(ownerId)) {
throw new AgentSavingsAccountClientMismatchException(accountNo);
}
// Commission is credited whenever a run posts, which only a savings account accepts.
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
if (depositType != SAVINGS_DEPOSIT_TYPE) {
throw new AgentSavingsAccountNotOperativeException(accountNo, depositType == 200 ? "fixed deposit" : "recurring deposit");
}
// A DDS scheme account is a savings account too, so the check above lets it through - but it
// belongs to a customer's scheme, and commission paid into it would be counted as that
// customer's deposits by every DDS interest run from then on.
final Long productId = ((Number) account.get("product_id")).longValue();
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
}
}
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("guarantors")) {
return;
}
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
if (guarantorsArray == null) {
return;
}
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
final String address1 = guarantorJson.get("address1").getAsString();
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
? guarantorJson.get("address2").getAsString()
: null;
final String city = guarantorJson.get("city").getAsString();
final String pinCode = guarantorJson.get("pinCode").getAsString();
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
? guarantorJson.get("telephone").getAsString()
: null;
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
}
}
private String generateNextAgentCode() {
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
return String.format("AG%04d", nextSuffix);
}
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.starter;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationAgentConfiguration {
@Bean
@ConditionalOnMissingBean(AgentReadPlatformService.class)
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AgentReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AgentWritePlatformService.class)
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
OfficeRepositoryWrapper officeRepositoryWrapper, GLAccountRepository glAccountRepository, PartyRepository partyRepository,
JdbcTemplate jdbcTemplate) {
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper,
officeRepositoryWrapper, glAccountRepository, partyRepository, jdbcTemplate);
}
}
@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/commissionslabs")
@Component
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
@RequiredArgsConstructor
public class CommissionSlabsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
private final PlatformSecurityContext context;
private final CommissionSlabReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
public Collection<CommissionSlabData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createCommissionSlabs() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{slabId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Correct a DDS Commission Slab", description = "Optional fields: slabType, slabFrom, slabTo, "
+ "commissionPercent. The corrected range is re-checked against the other slabs of its type and refused if it "
+ "overlaps one of them.")
public CommandProcessingResult update(@PathParam("slabId") @Parameter(description = "slabId") final Long slabId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateCommissionSlab(slabId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.domain;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
@Query("select s from CommissionSlab s where s.slabType = :slabType order by s.slabFrom")
List<CommissionSlab> findByType(@Param("slabType") String slabType);
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "COMMISSIONSLAB", action = "CREATE")
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
private final CommissionSlabWritePlatformService writePlatformService;
@Autowired
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createCommissionSlabs(command);
}
}
@@ -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);
}
}
@@ -0,0 +1,157 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class CommissionSlabCommandFromApiJsonDeserializer {
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public CommissionSlabCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (!this.fromApiJsonHelper.parameterExists("slabs", element)) {
v.reset().parameter("slabs").value(null).notNull();
} else {
final JsonObject topLevel = element.getAsJsonObject();
final var slabsArray = topLevel.getAsJsonArray("slabs");
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
final String slabType = extractString(slabJson, "slabType");
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
.notNull().zeroOrPositiveAmount();
}
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* A correction to one existing slab - a flat object, not the array the create endpoint takes,
* because only one row is ever being fixed.
*/
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists("slabType", element)) {
v.reset().parameter("slabType").value(this.fromApiJsonHelper.extractStringNamed("slabType", element)).notBlank()
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
}
if (this.fromApiJsonHelper.parameterExists("slabFrom", element)) {
v.reset().parameter("slabFrom").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabFrom", element))
.notNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("slabTo", element)) {
v.reset().parameter("slabTo").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabTo", element)).notNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
v.reset().parameter("commissionPercent")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
.zeroOrPositiveAmount();
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import java.util.Collection;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
public interface CommissionSlabReadPlatformService {
Collection<CommissionSlabData> retrieveAll();
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
public String schema() {
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
}
@Override
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return CommissionSlabData.builder() //
.id(rs.getLong("id")) //
.slabType(rs.getString("slabType")) //
.slabFrom(rs.getBigDecimal("slabFrom")) //
.slabTo(rs.getBigDecimal("slabTo")) //
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
.build();
}
}
@Override
public List<CommissionSlabData> retrieveAll() {
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface CommissionSlabWritePlatformService {
CommandProcessingResult createCommissionSlabs(JsonCommand command);
CommandProcessingResult updateCommissionSlab(Long slabId, JsonCommand command);
}
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
import org.apache.fineract.organisation.commissionslab.exception.OverlappingCommissionSlabException;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final CommissionSlabRepository commissionSlabRepository;
@Transactional
@Override
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final List<CommissionSlab> slabs = new ArrayList<>();
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
final CommissionSlab slab = new CommissionSlab(slabJson.get("slabType").getAsString(),
slabJson.get("slabFrom").getAsBigDecimal(), slabJson.get("slabTo").getAsBigDecimal(),
slabJson.get("commissionPercent").getAsBigDecimal());
// Checked against what is already stored and against the rest of this request, since one
// submission can just as easily carry two slabs that cover the same figure.
rejectIfOverlapping(slab, this.commissionSlabRepository.findByType(slab.getSlabType()));
rejectIfOverlapping(slab, slabs);
slabs.add(slab);
}
this.commissionSlabRepository.saveAllAndFlush(slabs);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
} catch (final PersistenceException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
}
}
@Transactional
@Override
public CommandProcessingResult updateCommissionSlab(final Long slabId, final JsonCommand command) {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
final Map<String, Object> changes = slab.update(command);
// Re-checked after the change, against every other slab of its type. A correction can just as
// easily create an overlap as a new row can - and the slab being corrected must not be
// compared with itself.
if (!changes.isEmpty()) {
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
.filter(other -> !other.getId().equals(slab.getId())).toList();
rejectIfOverlapping(slab, others);
this.commissionSlabRepository.saveAndFlush(slab);
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
}
/**
* Refuses a slab whose range overlaps one already covering part of the same figures.
*
* Two slabs matching the same collection volume leave the rate up to whichever row the query
* returns first, so an agent's percentage could change between runs with nothing in the data to
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
*/
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
for (final CommissionSlab other : existing) {
if (!other.getSlabType().equals(candidate.getSlabType())) {
continue;
}
final boolean overlaps = candidate.getSlabFrom().compareTo(other.getSlabTo()) <= 0
&& candidate.getSlabTo().compareTo(other.getSlabFrom()) >= 0;
if (overlaps) {
throw new OverlappingCommissionSlabException(candidate.getSlabType(), candidate.getSlabFrom(),
candidate.getSlabTo(), other.getSlabFrom(), other.getSlabTo());
}
}
}
}
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationCommissionSlabConfiguration {
@Bean
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
}
}
@@ -0,0 +1,739 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounting;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Where DDS money lands in the bank's own books.
*
* DDS used to keep its own balances and nothing else: a customer's money, an agent's commission and
* the cash an agent was carrying were all numbers on DDS tables that the bank's books never saw. This
* is the one place that changes - every DDS movement that is real money goes through here, becomes a
* transaction on a real account, and leaves a matching pair of journal entries behind.
*
* <p>
* The five movements and the entries each makes:
*
* <pre>
* Agent collects from a customer DR Agent Cash CR the account's control
* Agent settles at a branch DR Cash at Vault CR Agent Cash
* One customer pays another DR their control CR the other's control
* Interest is posted DR Interest on Savings CR the account's control
* Commission is paid DR Commission Expense CR the account's control
* </pre>
*
* The savings module's own accounting is deliberately skipped on these deposits. Left to itself it
* debits the product's fund source - the entry an ordinary counter deposit makes, "cash came in over
* the counter". None of these is that: a collection is cash that reached an agent rather than a till,
* and interest and commission are expenses the bank incurred with no cash arriving at all. So the
* deposit is recorded without its accounting and the true pair is written here instead.
*
* <p>
* Every account is looked up rather than named in code - the ledger accounts from settings a bank can
* repoint at accounts of their own, and each customer's control account from the product's own
* mapping. Nothing here is written if any of them is missing: it throws, and the caller's whole
* transaction rolls back. Money that moves without an entry behind it is worse than money that has
* not moved, because the balance changes and the books do not.
*/
@Component
@RequiredArgsConstructor
public class DdsLedger {
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
public static final String AGENT_CASH = "dds-agent-cash-account";
/** What an agent's commission costs the bank. Set by 0491. */
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
/** The savings product DDS opens customer accounts under. Set by 0492. */
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
/**
* The accounts money can be parked in for a customer with no account here. Every bank names its
* Sundry account differently, so none is assumed: every open posting liability account is offered
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
* now owes the money - an income or expense account would book it as the bank's own. Posting,
* because a header cannot take an entry. And never an account whose balance is meant to equal the
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
* account, a current-account control, an agent's own cash account - since parked money in any of
* those would leave it no longer adding up.
*/
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
+ "from acc_gl_account g "
+ "where g.classification_enum = 2 and g.account_usage = 1 and g.disabled = false "
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
+ "order by g.gl_code";
/**
* product_type 2 is savings. financial_account_type 2 is the control account - what the customer
* is owed - and 1 is the fund source, the till or vault cash moves through.
*/
// Oldest first. A current product that shares its id with a savings product writes its mapping
// under the same product_type, and the two sets of rows cannot be told apart; the savings product's
// were there first, so the first row is the savings product's rather than whichever the database
// happens to return. See 0519 for the current-account side of the same collision.
private static final String PRODUCT_ACCOUNT_SQL = "select gl_account_id from acc_product_mapping "
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null order by id";
private static final int CONTROL_ACCOUNT = 2;
private static final int FUND_SOURCE = 1;
/** What interest paid to a customer costs the bank. */
private static final int INTEREST_ON_SAVINGS = 3;
private final SavingsAccountAssembler savingsAccountAssembler;
private final SavingsAccountDomainService savingsAccountDomainService;
private final JournalEntryRepository journalEntryRepository;
private final GLAccountRepository glAccountRepository;
private final JdbcTemplate jdbcTemplate;
/** Who a current-account deposit row is recorded as made by - see payOutToCurrentAccount. */
private final PlatformSecurityContext context;
private final DdsVoucherService ddsVoucherService;
/**
* Runs a DDS operation and raises one voucher, in the DDS series, for everything it moved.
* Every service that moves money reaches the ledger through here, so the voucher comes with it.
*/
public <T> T withVoucher(final String voucherType, final String referenceType, final Long referenceId,
final java.util.function.Supplier<T> operation) {
return this.ddsVoucherService.raise(voucherType, referenceType, referenceId, operation);
}
/**
* A voucher side for a move the ledger does not show: two DDS accounts both sit on DDS Customer
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
*/
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
}
/**
* Credits a customer's own deposit account with what an agent collected from them.
*
* The money is real from the customer's side the moment the agent takes it, so it lands on their
* account now rather than when the agent gets round to a branch. What the bank does not have yet
* is the cash itself - it is in the agent's bag - which is what the Agent Cash side records.
*/
public SavingsAccountTransaction collect(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description, final GLAccount agentCashAccount) {
final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, agentCashOr(agentCashAccount), controlAccountOf(account), description);
return deposit;
}
/**
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
* were credited when their money was collected; this is the bank catching up with the cash.
*/
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount agentCashAccount) {
final Long productId = depositProductId();
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
description, null, null);
}
/**
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
*
* The cash is already in the bank: the cashier took it from the agent and credited the agent's GL
* account. What moves here is who it belongs to - off the agent's account and onto what the bank
* owes its DDS customers - so the debit is the very account the receipts credited, never looked up
* afresh. One pair for the whole application rather than one per customer: each customer's share is
* on their own DDS account, and a thousand identical pairs would say nothing the total does not.
*
* <pre>
* DR the account the receipts credited the agent's account clears
* CR DDS Customer Deposits what the bank now owes the customers
* </pre>
*/
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
final String transactionId, final String description) {
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
null, null);
}
/**
* The same hand-on for a party still on a savings account from before DDS kept its own: credited on
* that account, as a collection always was, with the pair on the account's own control.
*/
public void applyAgentReceiptToSavings(final Party party, final LocalDate date, final BigDecimal amount,
final GLAccount receiptAccount, final String description) {
final SavingsAccount account = load(party.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, receiptAccount, controlAccountOf(account), description);
}
// ---------------------------------------------------------------------------------------------
// A party's account, wherever it is kept.
//
// A DDS account is DDS's own now (0514): the balance is on the party, and what the bank owes for it
// sits on DDS Customer Deposits. A party opened before that still has a savings account behind it.
// Each movement below asks which of the two this party has and books it there - the same entry in
// the same direction either way, only the account that stands for "what the customer is owed"
// differs. Callers no longer choose; they name the party.
// ---------------------------------------------------------------------------------------------
/** A collection straight onto the party's account: Dr the agent's cash, Cr what the customer is owed. */
public void collect(final Party party, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount agentCashAccount) {
if (party.getSavingsAccountId() != null) {
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
return;
}
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
}
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
public void postInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
if (party.getSavingsAccountId() != null) {
postInterest(party.getSavingsAccountId(), date, amount, description);
return;
}
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
}
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
public void reverseInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
if (party.getSavingsAccountId() != null) {
reverseInterest(party.getSavingsAccountId(), date, amount, description);
return;
}
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
}
/**
* The party's whole balance paid into another account of the customer's at this bank. Nothing leaves
* the bank: what it owed on the DDS account it now owes on theirs.
*/
public void payOutToOwnAccount(final Party party, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
if (party.getSavingsAccountId() != null) {
payOutToOwnAccount(party.getSavingsAccountId(), toSavingsAccountId, date, amount, description);
return;
}
final SavingsAccount to = load(toSavingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
}
/**
* The party's whole balance paid into the customer's current account at this bank.
*
* Written onto the current account the way the bank's other book transfers into one are (NACH,
* clerk disbursement): a deposit row and the balances, directly, so the current-account module
* posts nothing of its own - and the pair here is the whole of the entry. Nothing leaves the bank:
* what it owed on the DDS account it now owes on the current account.
*
* <pre>
* DR what the customer was owed DDS Customer Deposits, or the party's savings control
* CR the current account's control
* </pre>
*/
public void payOutToCurrentAccount(final Party party, final Long currentAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final GLAccount owed;
final Office office;
if (party.getSavingsAccountId() != null) {
final SavingsAccount account = load(party.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
owed = controlAccountOf(account);
office = account.office();
} else {
owed = configured(DEPOSIT_LIABILITY);
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
}
creditCurrentAccount(currentAccountId, date, amount, description);
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
null, null);
}
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
private static final String CURRENT_DEPOSIT_SQL = "insert into m_current_account_transaction "
+ " (current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, is_manual, "
+ " is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ " values (?, ?, 1, ?, ?, false, false, false, false, ?, ?, ?, ?, current_timestamp, ?)";
private static final String CURRENT_BALANCE_SQL = "update m_current_account set account_balance_derived = account_balance_derived + ?, "
+ " total_deposits_derived = coalesce(total_deposits_derived, 0) + ? where id = ?";
private static final String CURRENT_CONTROL_SQL = "select apm.gl_account_id from m_current_account ca "
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
+ " where name = 'dds-current-account-control-account' and enabled = true";
/** A deposit row and the balances on the current account, refused unless it is active. */
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
if (rows.isEmpty() || ((Number) rows.get(0).get("status_enum")).intValue() != 300) {
throw new DdsLedgerNotConfiguredException("an active current account " + currentAccountId + " to pay into");
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final java.sql.Date day = java.sql.Date.valueOf(date);
final String remarks = description.length() > 490 ? description.substring(0, 490) : description;
this.jdbcTemplate.update(CURRENT_DEPOSIT_SQL, currentAccountId, rows.get(0).get("office_id"), day, amount, // NOSONAR
UUID.randomUUID().toString().replace("-", "").substring(0, 20), day, balance.add(amount), remarks,
this.context.authenticatedUser().getId());
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
}
/**
* The current account's control account: its product's mapping when that is the only one, and the
* setting when the product shares an id - and so its mapping rows - with a savings product.
*/
private GLAccount currentAccountControl(final Long currentAccountId) {
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
if (ids.size() == 1) {
return this.glAccountRepository.findById(ids.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
}
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
if (!override.isEmpty() && override.get(0) != null) {
return this.glAccountRepository.findById(override.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
}
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
}
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
public void payOutToPayable(final Party party, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount parkIn) {
if (party.getSavingsAccountId() != null) {
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
return;
}
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
}
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
public List<Map<String, Object>> sundryAccounts() {
return this.jdbcTemplate.queryForList(SUNDRY_ACCOUNTS_SQL); // NOSONAR
}
/**
* The account to park in: the one the clerk picked, when it is on the Sundry list. There is no
* fallback - every bank's Sundry account is its own, so none is guessed. Anything off the list is
* refused - an income or asset account, or a customer's own deposits, would put the money
* somewhere nobody would think to look for it.
*/
public GLAccount sundryAccount(final Long glAccountId) {
if (glAccountId == null) {
final String message = "Pick the Sundry GL account the balance is to be parked in.";
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.required", message,
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.required", message, "sundryGlAccountId")));
}
final boolean allowed = sundryAccounts().stream().anyMatch(row -> glAccountId.equals(((Number) row.get("id")).longValue()));
if (!allowed) {
final String message = "GL account " + glAccountId + " cannot hold a parked DDS balance - pick an open liability account that is not a customer or agent control account.";
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.invalid", message,
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.invalid", message, "sundryGlAccountId")));
}
return this.glAccountRepository.findById(glAccountId).orElseThrow();
}
/**
* Money moved from one party's account to another's.
*
* Two DDS accounts are both owed on DDS Customer Deposits, so the bank owes the same on the same
* account before and after and there is nothing to book - the two DDS balances are the whole
* movement. Only when one side is still a savings account does the money change ledger account.
*/
public void transfer(final Party from, final Party to, final LocalDate date, final BigDecimal amount, final String description) {
final boolean fromSavings = from.getSavingsAccountId() != null;
final boolean toSavings = to.getSavingsAccountId() != null;
if (fromSavings && toSavings) {
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
} else if (fromSavings) {
final SavingsAccount account = load(from.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
configured(DEPOSIT_LIABILITY), description, null, account.getId());
} else if (toSavings) {
final SavingsAccount account = load(to.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
} else {
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
voucherSideOnDeposits("DR", from, amount, description);
voucherSideOnDeposits("CR", to, amount, description);
}
}
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
final String description) {
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
}
/**
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
* when they have none.
*
* Only the account differs. Both sides of the entry, their direction and the amount are the same
* either way - an agent with their own account is not accounted for differently, only separately.
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
* set up before this existed posts exactly as they always did, and a branch can move agents onto
* their own accounts one at a time.
*/
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
}
/**
* Credits a customer's account with interest their deposits have earned.
*
* The interest is worked out by the DDS run on the daily product, not by the savings product -
* which is why the product carries a zero rate - but it is paid like any other interest: an
* expense to the bank, and more owed to the customer.
*/
public SavingsAccountTransaction postInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, productAccount(account.productId(), INTEREST_ON_SAVINGS), controlAccountOf(account),
description);
return deposit;
}
/**
* Takes back interest a customer has been credited but, on closing early, is not entitled to.
*
* The exact opposite of {@link #postInterest}: the bank owes the customer less, and its interest
* expense is reduced by what it turned out not to owe. It is written as its own movement rather
* than by quietly paying out less, so the passbook shows the interest that was credited and the
* part that came back - a customer who is paid a smaller figure than their last statement showed
* can see exactly why.
*
* <pre>
* DR the account's control the customer's balance comes down
* CR Interest on Savings the bank's interest expense is reduced
* </pre>
*/
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
}
/**
* Moves money from one customer's account to another's.
*
* The bank is no better or worse off - it owed the money before and owes it after, just to
* somebody else - so the two halves fall on the same control account and cancel. What changes is
* which customer's balance holds it, and that is what the two account transactions record.
*/
public void transfer(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final SavingsAccount from = load(fromSavingsAccountId);
final SavingsAccount to = load(toSavingsAccountId);
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
// movement is DDS's own, so the block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(from);
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(from, wasBlocked);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
}
/**
* Pays a matured deposit into another account of the customer's at this bank.
*
* The whole balance leaves the DDS account and lands on theirs. Both are the bank's own accounts,
* so nothing leaves the bank - it owes the same money to the same person, in a different place -
* which is why this is the same movement as a transfer between two customers.
*/
public void payOutToOwnAccount(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date,
final BigDecimal amount, final String description) {
transfer(fromSavingsAccountId, toSavingsAccountId, date, amount, description);
}
/**
* Takes a matured deposit off the customer's account and parks it, ready to be sent to another
* bank.
*
* The money leaves the DDS account today; the NEFT or IMPS clears later, or fails and is tried
* again. Sending it straight out would leave a window where it has left one account and arrived
* at none, and a failed transfer would leave nothing accounting for it at all. So it waits in the
* payable account, which says at any moment exactly what the bank is holding and still owes.
*
* <pre>
* DR the account's control the customer's balance goes
* CR Matured Deposits Payable and the bank now owes it to them
* </pre>
*/
private void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount parkIn) {
final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
description, null, account.getId());
}
/**
* Brings a balance a party already had onto its newly opened account.
*
* These are customers whose money DDS held before it opened real accounts: the bank has the cash -
* it was collected and banked over months - but its own books carry no record of either the cash
* or what it owes them. This writes both sides at once, which is what an opening entry is for.
*
* <pre>
* DR the product's fund source the cash the bank already holds
* CR the account's control what it owes the customer
* </pre>
*
* The debit assumes the cash reached the bank rather than still sitting with an agent. That holds
* for a settled balance and is the ordinary case; a bank whose agents were mid-round when this ran
* should reconcile it. It is the one assumption here that an accountant, not this code, should
* confirm.
*/
public SavingsAccountTransaction openingBalance(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, productAccount(account.productId(), FUND_SOURCE), controlAccountOf(account), description);
return deposit;
}
/** Credits an agent's own savings account with commission the bank owes them. */
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
return deposit;
}
/**
* Blocks withdrawals on a scheme account, leaving deposits alone.
*
* A DDS account is a savings account, and without this a customer can empty a twelve-month
* scheme at the counter the day after opening it - which is the one thing a daily deposit scheme
* exists to prevent. Worse, that withdrawal happens outside DDS: the scheme balance and the
* account balance part company, every report misstates the book, and the maturity payout later
* fails for want of funds.
*
* Deposits are untouched, so the agent collects as before and interest posts as before. Only the
* way out is closed, and only from outside - DDS lifts it for its own payouts and transfers.
*/
public void lockDebits(final Long savingsAccountId) {
final SavingsAccount account = load(savingsAccountId);
if (!isDebitBlocked(account)) {
account.blockDebits(account.getSubStatus());
}
}
/**
* Lifts the block for a movement DDS is making itself, saying whether it had to.
*
* The caller puts it back with {@link #relockDebits}. An account that was not blocked - one
* opened before the block existed, say - is left as it is rather than being blocked for the
* first time on its way out of the scheme.
*/
private boolean unlockDebitsIfBlocked(final SavingsAccount account) {
if (!isDebitBlocked(account)) {
return false;
}
account.unblockDebits();
return true;
}
/** Puts the block back after one of DDS's own withdrawals, if this call was what lifted it. */
private void relockDebits(final SavingsAccount account, final boolean wasBlocked) {
if (wasBlocked) {
account.blockDebits(account.getSubStatus());
}
}
private boolean isDebitBlocked(final SavingsAccount account) {
return SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()));
}
// ---------------------------------------------------------------------------------------------
private SavingsAccount load(final Long savingsAccountId) {
return this.savingsAccountAssembler.assembleFrom(savingsAccountId, this.savingsAccountAssembler.getPivotConfigStatus());
}
private SavingsAccountTransaction depositWithoutAccounting(final SavingsAccount account, final LocalDate date,
final BigDecimal amount) {
return this.savingsAccountDomainService.handleDeposit(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, false, true,
this.savingsAccountAssembler.getPivotConfigStatus(), true);
}
private void postPair(final SavingsAccount account, final SavingsAccountTransaction deposit, final LocalDate date,
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description) {
postPair(account.office(), account.getCurrency().getCode(), date, amount, debit, credit, description, deposit.getId(),
account.getId());
}
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
final Long savingsAccountId) {
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
}
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
final Long savingsTransactionId, final Long savingsAccountId) {
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
DdsVoucherScope.recordLedgerTransaction(transactionId);
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
}
}
/** A ledger account named by a setting, so a bank can point it at one of their own. */
private GLAccount configured(final String settingName) {
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
if (ids.isEmpty() || ids.get(0) == null) {
throw new DdsLedgerNotConfiguredException(settingName);
}
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
}
private GLAccount controlAccountOf(final SavingsAccount account) {
return productAccount(account.productId(), CONTROL_ACCOUNT);
}
/**
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
* so its ledger accounts and currency come from the product rather than from an account.
*/
private Long depositProductId() {
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
if (ids.isEmpty() || ids.get(0) == null) {
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
}
return ids.get(0);
}
/** The bank's own currency, for an entry that belongs to no savings product. */
private String organisationCurrency() {
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
if (codes.isEmpty()) {
throw new DdsLedgerNotConfiguredException("organisation currency");
}
return codes.get(0);
}
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
private String currencyOf(final Long productId) {
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
if (codes.isEmpty()) {
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
}
return codes.get(0);
}
/** An account off the savings product's own mapping, so this stays right per product. */
private GLAccount productAccount(final Long productId, final int financialAccountType) {
final List<Long> ids = this.jdbcTemplate.queryForList(PRODUCT_ACCOUNT_SQL, Long.class, productId, financialAccountType); // NOSONAR
final String what = financialAccountType == CONTROL_ACCOUNT ? "control account" : "fund source";
if (ids.isEmpty()) {
throw new DdsLedgerNotConfiguredException(what + " for savings product " + productId);
}
return this.glAccountRepository.findById(ids.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException(what + " for savings product " + productId));
}
}
@@ -0,0 +1,450 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounting;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Raises the voucher for a DDS operation in the bank's own voucher register.
*
* Nothing about vouchers is kept by DDS itself: the number, the register and its lines are the ones
* every other module writes through {@link VoucherService}, in the same PREFIX/DDMMYYYY/NNNN form -
* only the prefix is DDS's own, so its slips read DDS/29092026/0001 and run as a series of their own.
*
* An operation is wrapped in {@link #raise}. Its ledger legs and passbook lines are noted as they are
* written (see {@link DdsVoucherScope}); when it finishes, one voucher is raised from the legs it
* actually wrote, and its number stamped on the passbook lines and on any savings entry it made. A
* wrap inside another adds to the outer one, so an early closure that also brings interest up to date
* is one slip, not two.
*
* The voucher is raised in the operation's own transaction, so an operation that rolls back - one held
* for a checker's approval, say - leaves no voucher behind, and the number is taken only when the money
* actually moves.
*/
@Component
@RequiredArgsConstructor
public class DdsVoucherService {
/** The series DDS vouchers are numbered in. */
public static final String PREFIX = "DDS";
public static final String TYPE_APPLY_RECEIPT = "DDS_APPLY_RECEIPT";
public static final String TYPE_INTEREST = "DDS_INTEREST";
public static final String TYPE_COMMISSION = "DDS_COMMISSION";
public static final String TYPE_MATURITY_PAYOUT = "DDS_MATURITY_PAYOUT";
public static final String TYPE_PREMATURE = "DDS_PREMATURE";
public static final String TYPE_TRANSFER = "DDS_TRANSFER";
public static final String TYPE_TRANSACTION = "DDS_TRANSACTION";
public static final String REF_PARTY = "DDS_PARTY";
public static final String REF_BATCH_RUN = "DDS_BATCH_RUN";
public static final String REF_AGENT = "DDS_AGENT";
/** The ledger id DdsLedger gives a leg posted for one party: DDS-PT0003-0fdd3261. */
private static final Pattern PARTY_LEG = Pattern.compile("^DDS-(.+)-[0-9a-f]{8}$");
private static final String LEGS_SQL = "select je.type_enum as \"typeEnum\", je.amount, je.account_id as \"glId\", "
+ "gl.name as \"glName\", gl.gl_code as \"glCode\", je.description, je.transaction_id as \"transactionId\", "
+ "je.savings_transaction_id as \"savingsTxnId\" from acc_gl_journal_entry je "
+ "join acc_gl_account gl on gl.id = je.account_id "
+ "where je.transaction_id in (%s) and (je.reversed is null or je.reversed = false) order by je.id";
private static final String PARTY_SQL = "select party_name as \"name\", account_no as \"accountNo\" from m_dds_party "
+ "where party_code = ?";
private static final String SAVINGS_OWNER_SQL = "select sa.id as \"savingsAccountId\", sa.account_no as \"accountNo\", "
+ "c.display_name as \"name\", apm.gl_account_id as \"controlGlId\" from m_savings_account_transaction t "
+ "join m_savings_account sa on sa.id = t.savings_account_id left join m_client c on c.id = sa.client_id "
+ "left join acc_product_mapping apm on apm.product_id = sa.product_id and apm.product_type = 2 "
+ "and apm.financial_account_type = 2 where t.id = ? order by apm.id limit 1";
private static final String SYSTEM_USER_SQL = "select id from m_appuser where username = 'system' and is_deleted = false limit 1";
/** How a voucher held for an entry awaiting approval is tied to that entry: DDS_COMMAND:{commandId}. */
public static final String HELD_FOR_COMMAND = "DDS_COMMAND:";
/** The entry being approved on this thread, so the voucher held for it is the one used. */
private static final ThreadLocal<Long> APPROVING_COMMAND = new ThreadLocal<>();
/**
* A voucher held for an entry that is waiting on a checker and has not been used yet. Held while
* the entry is pending (2), or just approved (1) for a posting run that finishes in the background;
* never one whose entry was rejected (3).
*/
private static final String HELD_VOUCHER_SQL = "select v.voucher_no from m_voucher v "
+ "join m_portfolio_command_source c on v.reference_key = 'DDS_COMMAND:' || c.id and c.status in (1, 2) "
+ "where v.voucher_no like 'DDS/%' and not exists (select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) "
+ "and v.voucher_type = ? and v.reference_type = ? and v.reference_id = ? "
+ "order by (v.amount = ?) desc, v.id limit 1";
/** The last voucher raised on this thread - read by the approvals screen to show what an approval produced. */
private static final ThreadLocal<String> LAST_ISSUED = new ThreadLocal<>();
private final VoucherService voucherService;
/** Flushed before the voucher is raised, so every passbook line the operation wrote has its id. */
@PersistenceContext
private EntityManager entityManager;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final AppUserRepository appUserRepository;
/**
* Runs one DDS operation and raises its voucher.
*
* @return whatever the operation returns
*/
public <T> T raise(final String voucherType, final String referenceType, final Long referenceId, final Supplier<T> operation) {
if (DdsVoucherScope.current() != null) {
// Part of a larger operation, whose voucher this becomes a part of.
return operation.get();
}
LAST_ISSUED.remove();
final DdsVoucherScope scope = DdsVoucherScope.open();
try {
// Any savings posting inside is DDS's own movement and part of this voucher, not a slip of its own.
final T result = this.voucherService.withOwnVoucher(operation);
issue(scope, voucherType, referenceType, referenceId);
return result;
} finally {
DdsVoucherScope.close();
}
}
/**
* Sends an entry for approval, numbering its voucher there and then.
*
* An entry held for a checker has moved nothing yet, but the checker approves it against a voucher
* number - so the number is taken now, with the amount the entry is for, and the entry is tied to
* it. When the entry is approved the money moves under that same number; when it is rejected the
* number is marked cancelled and never used. An entry that needs no checker is carried out at
* once and numbered as it moves, as before.
*/
public CommandProcessingResult submitHoldingVoucher(final String voucherType, final String referenceType, final Long referenceId,
final Supplier<BigDecimal> amount, final Supplier<CommandProcessingResult> submit) {
try {
return submit.get();
} catch (final RollbackTransactionNotApprovedException held) {
final Long commandId = held.getResult().getCommandId();
final AppUser user = currentUser();
if (voucherType != null && commandId != null && user != null) {
final BigDecimal value = amount.get();
this.voucherService.createVoucher(voucherType, referenceType, referenceId, HELD_FOR_COMMAND + commandId,
value == null ? BigDecimal.ZERO : value, user, PREFIX);
}
throw held;
}
}
/** Runs an approval so the voucher held for that entry is the one its money moves under. */
public <T> T approving(final Long commandId, final Supplier<T> approval) {
APPROVING_COMMAND.set(commandId);
try {
return approval.get();
} finally {
APPROVING_COMMAND.remove();
}
}
/** Marks the voucher held for a rejected entry cancelled, so it is never used. */
public void cancelHeld(final Long commandId) {
this.jdbcTemplate.update("update m_voucher set reference_key = reference_key || ':REJECTED' where reference_key = ?", // NOSONAR
HELD_FOR_COMMAND + commandId);
}
private String heldVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal total) {
final Long approving = APPROVING_COMMAND.get();
if (approving != null) {
final List<String> exact = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher v where reference_key = ? and not exists "
+ "(select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) order by id limit 1",
String.class, HELD_FOR_COMMAND + approving);
if (!exact.isEmpty()) {
return exact.get(0);
}
}
if (referenceType == null || referenceId == null) {
return null;
}
// Approved from elsewhere - the Checker Inbox - or a posting run finishing in the background.
final List<String> found = this.jdbcTemplate.queryForList(HELD_VOUCHER_SQL, String.class, voucherType, referenceType, // NOSONAR
referenceId, total);
return found.isEmpty() ? null : found.get(0);
}
/** What a payout or early closure of this account will move: its balance, with interest worked out but not posted. */
public BigDecimal amountHeldBy(final Long partyId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select current_balance + coalesce(interest_calc, 0) from m_dds_party where id = ?", BigDecimal.class, partyId);
return found.isEmpty() ? null : found.get(0);
}
/** The voucher a posting run raises, by its job type - the same types the run itself uses. */
public String voucherTypeOfRun(final Long batchRunId) {
final List<String> found = this.jdbcTemplate.queryForList("select job_type from m_dds_batch_run where id = ?", String.class, // NOSONAR
batchRunId);
final String jobType = found.isEmpty() ? "" : found.get(0);
return switch (jobType) {
case "COMMISSION_POSTING" -> TYPE_COMMISSION;
case "MATURITY_AUTO_CLOSE" -> TYPE_MATURITY_PAYOUT;
case "INTEREST_POSTING", "OD_INTEREST_POSTING" -> TYPE_INTEREST;
// A calculation run moves no money, so no voucher is held for it.
default -> null;
};
}
/** What a posting run will move. */
public BigDecimal amountOfRun(final Long batchRunId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select total_amount from m_dds_batch_run where id = ?", // NOSONAR
BigDecimal.class, batchRunId);
return found.isEmpty() ? null : found.get(0);
}
/** What an agent's day will apply: every entry of theirs still waiting to be settled. */
public BigDecimal amountPendingFor(final Long agentId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(sum(amount), 0) from m_dds_collection_entry where agent_id = ? and status = 'PENDING'", BigDecimal.class,
agentId);
return found.isEmpty() ? null : found.get(0);
}
/** A number read from a request body, or null. */
public static BigDecimal numberIn(final String json, final String name) {
final JsonObject body = bodyOf(json);
try {
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsBigDecimal() : null;
} catch (RuntimeException e) {
return null;
}
}
/** A whole number read from a request body, or null. */
public static Long idIn(final String json, final String name) {
final JsonObject body = bodyOf(json);
try {
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsLong() : null;
} catch (RuntimeException e) {
return null;
}
}
private static JsonObject bodyOf(final String json) {
try {
final JsonElement element = json == null ? null : JsonParser.parseString(json);
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
} catch (RuntimeException e) {
return new JsonObject();
}
}
/** The voucher the last operation on this thread raised, or null, clearing it. */
public String takeLastIssued() {
final String voucherNo = LAST_ISSUED.get();
LAST_ISSUED.remove();
return voucherNo;
}
/** As {@link #raise}, for an operation that returns nothing. */
public void raise(final String voucherType, final String referenceType, final Long referenceId, final Runnable operation) {
raise(voucherType, referenceType, referenceId, () -> {
operation.run();
return null;
});
}
private void issue(final DdsVoucherScope scope, final String voucherType, final String referenceType, final Long referenceId) {
// A passbook line saved after the operation's last ledger write would otherwise still be waiting
// to be inserted - noted in the scope only once it is - and would miss its voucher number.
try {
this.entityManager.flush();
} catch (RuntimeException notInTransaction) {
// Nothing pending to write when there is no transaction to flush.
}
final Set<Long> savingsTransactions = new LinkedHashSet<>();
final List<VoucherLine> lines = linesFromLedger(scope.getLedgerTransactionIds(), savingsTransactions);
for (final DdsVoucherScope.Line extra : scope.getExtraLines()) {
final VoucherLine line = new VoucherLine(extra.entryType(), extra.glAccountId(), null, null, extra.partyName(),
extra.accountNo(), extra.amount(), null);
lines.add(extra.description() == null ? line : line.withDescription(extra.description()));
}
if (lines.isEmpty()) {
// Nothing moved - a calculation run, or an operation that found nothing to do.
return;
}
final BigDecimal total = lines.stream().filter(VoucherLine::isDebit).map(VoucherLine::amount).reduce(BigDecimal.ZERO,
BigDecimal::add);
final String held = heldVoucher(voucherType, referenceType, referenceId, total);
final String voucherNo;
if (held != null) {
// Numbered when the entry was sent for approval, so the checker saw it; its sides are the
// legs written now that the money has moved, and its amount what actually moved.
this.voucherService.addLines(held, lines);
this.jdbcTemplate.update("update m_voucher set amount = ? where voucher_no = ?", total, held); // NOSONAR
voucherNo = held;
} else {
final AppUser user = currentUser();
if (user == null) {
throw new IllegalStateException("No user to raise the DDS voucher as - the system user is missing.");
}
voucherNo = this.voucherService.createVoucherWithLines(voucherType, referenceType, referenceId, user, PREFIX, lines);
}
LAST_ISSUED.set(voucherNo);
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
savingsTransactions);
}
/**
* The voucher's sides, read from the legs the operation wrote.
*
* A leg on a customer's own account - a DDS account, or a savings account the money was paid into -
* is named after the customer and their account, one line each, since the slip has to show who was
* paid what. Every other leg - interest expense, commission expense, a Sundry account - is an
* account of the bank's, and its legs are added up into one line per side, so a posting run over
* four hundred accounts shows one expense line against four hundred credits.
*/
private List<VoucherLine> linesFromLedger(final Set<String> transactionIds, final Set<Long> savingsTransactions) {
final List<VoucherLine> customerLines = new ArrayList<>();
final Map<String, BigDecimal> bankTotals = new LinkedHashMap<>();
final Map<String, Map<String, Object>> bankAccounts = new LinkedHashMap<>();
if (transactionIds.isEmpty()) {
return new ArrayList<>();
}
final Long depositGlId = depositLiabilityGlId();
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
transactionIds.toArray())) {
final boolean debit = ((Number) leg.get("typeEnum")).intValue() == 2;
final String side = debit ? VoucherService.ENTRY_DEBIT : VoucherService.ENTRY_CREDIT;
final BigDecimal amount = (BigDecimal) leg.get("amount");
final Long glId = ((Number) leg.get("glId")).longValue();
final String description = (String) leg.get("description");
final Long savingsTxnId = leg.get("savingsTxnId") == null ? null : ((Number) leg.get("savingsTxnId")).longValue();
if (savingsTxnId != null) {
savingsTransactions.add(savingsTxnId);
final Map<String, Object> owner = this.jdbcTemplate.queryForList(SAVINGS_OWNER_SQL, savingsTxnId).stream().findFirst() // NOSONAR
.orElse(Map.of());
final Object controlGl = owner.get("controlGlId");
if (controlGl != null && glId.equals(((Number) controlGl).longValue())) {
final Long savingsAccountId = ((Number) owner.get("savingsAccountId")).longValue();
final VoucherLine line = debit
? VoucherLine.debitSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount)
: VoucherLine.creditSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount);
customerLines.add(description == null ? line : line.withDescription(description));
continue;
}
}
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
if (partyLeg.matches() && glId.equals(depositGlId)) {
final String partyCode = partyLeg.group(1);
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
final String name = party.get("name") == null ? partyCode : partyCode + " · " + party.get("name");
final VoucherLine line = new VoucherLine(side, glId, null, null, name, (String) party.get("accountNo"), amount, null);
customerLines.add(description == null ? line : line.withDescription(description));
continue;
}
final String key = side + ":" + glId;
bankTotals.merge(key, amount, BigDecimal::add);
bankAccounts.putIfAbsent(key, leg);
}
final List<VoucherLine> lines = new ArrayList<>();
// Debits first, as a slip reads: where the money came from, then where it went.
for (final String side : List.of(VoucherService.ENTRY_DEBIT, VoucherService.ENTRY_CREDIT)) {
for (final Map.Entry<String, BigDecimal> total : bankTotals.entrySet()) {
if (total.getKey().startsWith(side + ":")) {
final Map<String, Object> account = bankAccounts.get(total.getKey());
lines.add(new VoucherLine(side, ((Number) account.get("glId")).longValue(), null, null, (String) account.get("glName"),
(String) account.get("glCode"), total.getValue(), null));
}
}
for (final VoucherLine line : customerLines) {
if (side.equals(line.entryType())) {
lines.add(line);
}
}
}
return lines;
}
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
private Long depositLiabilityGlId() {
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
return ids.isEmpty() ? null : ids.get(0);
}
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
if (ids.isEmpty()) {
return;
}
final List<Object> params = new ArrayList<>();
params.add(voucherNo);
params.addAll(ids);
this.jdbcTemplate.update(String.format(sql, placeholders(ids)), params.toArray()); // NOSONAR
}
/** Who raised it: the user at the screen, or the system user for the nightly job. */
private AppUser currentUser() {
final AppUser user = this.context.getAuthenticatedUserIfPresent();
if (user != null) {
return user;
}
final List<Long> ids = this.jdbcTemplate.queryForList(SYSTEM_USER_SQL, Long.class); // NOSONAR
return ids.isEmpty() ? null : this.appUserRepository.findById(ids.get(0)).orElse(null);
}
private static String placeholders(final Collection<?> values) {
return String.join(",", Collections.nCopies(values.size(), "?"));
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,155 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounttype.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.stereotype.Component;
@Component
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
"dateFormat", "locale"));
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private final FromJsonHelper fromApiJsonHelper;
public DdsAccountTypeCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
final JsonElement element = parseAndCheckParameters(json);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element)).notBlank()
.notExceedingLengthOf(60);
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("accountTypeDescription")
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200);
// Mandatory: every account opened on the scheme takes this rate.
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
validateOptionalFields(element, v);
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
final JsonElement element = parseAndCheckParameters(json);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
if (this.fromApiJsonHelper.parameterExists("accountTypeGroup", element)) {
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element))
.notBlank().notExceedingLengthOf(60);
}
if (this.fromApiJsonHelper.parameterExists("accountTypeCode", element)) {
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("accountTypeDescription", element)) {
v.reset().parameter("accountTypeDescription")
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
validateOptionalFields(element, v);
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private JsonElement parseAndCheckParameters(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
return this.fromApiJsonHelper.parse(json);
}
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
if (this.fromApiJsonHelper.parameterExists("autoInterestPostWhen", element)) {
v.reset().parameter("autoInterestPostWhen")
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
v.reset().parameter("autoInterestPostDay")
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
.inMinMaxRange(1, 31);
}
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
if (this.fromApiJsonHelper.parameterExists("prematureLockinMonths", element)) {
v.reset().parameter("prematureLockinMonths")
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("prematureLockinMonths", element)).ignoreIfNull()
.inMinMaxRange(0, 600);
}
if (this.fromApiJsonHelper.parameterExists("prematureInterestReduction", element)) {
v.reset().parameter("prematureInterestReduction")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureInterestReduction", element))
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
if (this.fromApiJsonHelper.parameterExists("prematureAgentCommissionPenalty", element)) {
v.reset().parameter("prematureAgentCommissionPenalty")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureAgentCommissionPenalty", element))
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -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);
}
@@ -0,0 +1,100 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounttype.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final DdsAccountTypeMapper MAPPER = new DdsAccountTypeMapper();
private static final class DdsAccountTypeMapper implements RowMapper<DdsAccountTypeData> {
public String schema() {
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
+ " t.maturity_auto_close as maturityAutoClose, "
+ " t.premature_lockin_months as prematureLockinMonths, "
+ " t.premature_interest_reduction as prematureInterestReduction, "
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
+ " t.interest_rate as interestRate, "
+ " t.is_active as active, "
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
+ " from m_dds_account_type t";
}
@Override
public DdsAccountTypeData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return DdsAccountTypeData.builder() //
.id(rs.getLong("id")) //
.accountTypeGroup(rs.getString("accountTypeGroup")) //
.accountTypeCode(rs.getString("accountTypeCode")) //
.accountTypeDescription(rs.getString("accountTypeDescription")) //
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
.interestRate(rs.getBigDecimal("interestRate")) //
.active(rs.getBoolean("active")) //
.createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
.build();
}
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
final Timestamp timestamp = rs.getTimestamp(columnName);
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
@Override
public Collection<DdsAccountTypeData> retrieveAll() {
final String sql = "select " + MAPPER.schema() + " order by t.account_type_code";
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
}
@Override
public DdsAccountTypeData retrieveOne(final Long accountTypeId) {
try {
final String sql = "select " + MAPPER.schema() + " where t.id = ?";
return this.jdbcTemplate.queryForObject(sql, MAPPER, accountTypeId); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new DdsAccountTypeNotFoundException(accountTypeId, e);
}
}
}
@@ -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);
}
@@ -0,0 +1,128 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounttype.service;
import jakarta.persistence.PersistenceException;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsAccountTypeWritePlatformService {
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final DdsAccountTypeRepository ddsAccountTypeRepository;
private final PartyRepository partyRepository;
@Transactional
@Override
public CommandProcessingResult createDdsAccountType(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final DdsAccountType accountType = DdsAccountType.fromJson(command);
this.ddsAccountTypeRepository.saveAndFlush(accountType);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(accountType.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateDdsAccountType(final Long accountTypeId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
// The accounts on the scheme carry the code as it was before this update.
final String codeBeforeUpdate = accountType.getAccountTypeCode();
final Map<String, Object> changesOnly = accountType.update(command);
if (!changesOnly.isEmpty()) {
this.ddsAccountTypeRepository.saveAndFlush(accountType);
}
// The rate on the product is the rate: every open account on it moves to the new one, and
// its maturity figure is worked out again. Interest already calculated stays as it was;
// from here on it is worked out at the new rate.
if (changesOnly.containsKey("interestRate")) {
final List<Party> openAccounts = this.partyRepository.findActiveOnSubType(codeBeforeUpdate);
for (final Party party : openAccounts) {
party.takeSchemeRate(accountType.getInterestRate());
}
this.partyRepository.saveAll(openAccounts);
changesOnly.put("accountsRepriced", openAccounts.size());
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(accountTypeId) //
.with(changesOnly) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
"A DDS account type with this code already exists");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.dds.account.type.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.accounttype.starter;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsAccountTypeConfiguration {
@Bean
@ConditionalOnMissingBean(DdsAccountTypeReadPlatformService.class)
public DdsAccountTypeReadPlatformService ddsAccountTypeReadPlatformService(JdbcTemplate jdbcTemplate) {
return new DdsAccountTypeReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
PartyRepository partyRepository) {
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
}
}
@@ -0,0 +1,130 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.approval.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.approval.service.DdsApprovalReadService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* The DDS approvals queue: every DDS entry waiting on a checker, in one place, with what it will do
* and - once approved - the voucher it moved under.
*
* The entries are the platform's own maker-checker entries (m_portfolio_command_source); nothing is
* queued a second way. Approving one replays it through the platform's own approval, so the money
* moves then, and only then, and the DDS voucher is raised at that moment.
*
* One rule is held here more strictly than elsewhere: whoever made an entry cannot be the one to
* approve or reject it - not even a super user, who the platform otherwise lets check their own work.
*/
@Path("/v1/dds/approvals")
@Component
@Tag(name = "DDS Approvals", description = "DDS entries waiting on a checker - transfers, payouts, early closures, receipts applied, "
+ "postings, and changes to parties, agents and schemes.")
@RequiredArgsConstructor
public class DdsApprovalsApiResource {
private final PlatformSecurityContext context;
private final DdsApprovalReadService readService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "DDS entries by approval status", description = "status is PENDING (default), APPROVED or REJECTED; "
+ "fromDate and toDate (yyyy-MM-dd) narrow it to the days the entries were made.")
public List<Map<String, Object>> list(@QueryParam("status") @Parameter(description = "status") final String status,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
this.context.authenticatedUser();
return this.readService.list(status, fromDate, toDate);
}
@GET
@Path("{commandId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "One DDS entry, with what it will do")
public Map<String, Object> retrieveOne(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId) {
this.context.authenticatedUser();
return this.readService.retrieveOne(commandId);
}
@POST
@Path("{commandId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve or reject a DDS entry", description = "command=approve carries the entry out and returns the "
+ "voucher it moved under; command=reject leaves nothing done. Neither is open to the user who made the entry.")
public Map<String, Object> action(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId,
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
final AppUser user = this.context.authenticatedUser();
final Map<String, Object> entry = this.readService.pendingEntry(commandId);
final Number makerId = (Number) entry.get("makerId");
if (makerId != null && makerId.longValue() == user.getId()) {
throw new NoAuthorizationException("You made this entry, so you cannot " + ("reject".equalsIgnoreCase(commandParam) ? "reject" : "approve")
+ " it. Another user with checker rights has to.");
}
if ("approve".equalsIgnoreCase(commandParam)) {
this.ddsVoucherService.takeLastIssued();
// The money moves under the voucher numbered when the entry was sent for approval.
final CommandProcessingResult result = this.ddsVoucherService.approving(commandId,
() -> this.commandsSourceWritePlatformService.approveEntry(commandId));
String voucherNo = this.ddsVoucherService.takeLastIssued();
if (voucherNo == null) {
// A posting run finishes in the background; the voucher it will use is the one held for it.
voucherNo = this.readService.heldVoucher(commandId);
}
this.readService.linkVoucher(commandId, voucherNo);
return Map.of("commandId", commandId, "status", "APPROVED", "resourceId",
result.getResourceId() == null ? "" : result.getResourceId(), "voucherNo", voucherNo == null ? "" : voucherNo);
}
if ("reject".equalsIgnoreCase(commandParam)) {
this.commandsSourceWritePlatformService.rejectEntry(commandId);
// The number held for it is cancelled, never used for anything else.
final String voucherNo = this.readService.heldVoucher(commandId);
this.ddsVoucherService.cancelHeld(commandId);
return Map.of("commandId", commandId, "status", "REJECTED", "voucherNo", voucherNo == null ? "" : voucherNo);
}
throw new UnrecognizedQueryParamException("command", commandParam);
}
}
@@ -0,0 +1,503 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.approval.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Reads the DDS entries in the platform's maker-checker register and says, in the clerk's terms, what
* each one does: which accounts, how much, where the money goes, and - once it has moved - under which
* voucher.
*
* Only the DDS actions that are actually held for a checker are listed (their permission is marked for
* maker-checker), so the list is the queue of work, not every DDS command ever logged.
*/
@Component
@RequiredArgsConstructor
public class DdsApprovalReadService {
private static final int STATUS_PROCESSED = 1;
private static final int STATUS_AWAITING = 2;
private static final int STATUS_REJECTED = 3;
private static final String ENTRIES_SQL = "select c.id as \"id\", c.action_name as \"action\", c.entity_name as \"entity\", "
+ "c.resource_id as \"resourceId\", c.status as \"status\", c.command_as_json as \"json\", "
+ "to_char(c.made_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"madeOn\", to_char(c.checked_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"checkedOn\", "
+ "mk.id as \"makerId\", mk.username as \"maker\", ck.username as \"checker\", "
+ "trim(coalesce(mk.firstname, '') || ' ' || coalesce(mk.lastname, '')) as \"makerName\", "
+ "trim(coalesce(ck.firstname, '') || ' ' || coalesce(ck.lastname, '')) as \"checkerName\" "
+ "from m_portfolio_command_source c "
+ "join m_permission p on p.code = c.action_name || '_' || c.entity_name and p.can_maker_checker = true "
+ "left join m_appuser mk on mk.id = c.maker_id left join m_appuser ck on ck.id = c.checker_id "
+ "where c.entity_name in ('PARTY', 'AGENT', 'PARTYTRANSACTION', 'DDSACCOUNTTYPE', 'COMMISSIONSLAB', 'DDSBATCH', "
+ "'DDSCOLLECTIONENTRY') ";
private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
private final JdbcTemplate jdbcTemplate;
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
final List<Object> params = new ArrayList<>();
params.add(statusCode(status));
if (fromDate != null && !fromDate.isBlank()) {
sql.append(" and cast(c.made_on_date_utc as date) >= cast(? as date) ");
params.add(fromDate);
}
if (toDate != null && !toDate.isBlank()) {
sql.append(" and cast(c.made_on_date_utc as date) <= cast(? as date) ");
params.add(toDate);
}
sql.append(" order by c.id desc limit 500");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> entry : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) { // NOSONAR
final Map<String, Object> row = describe(entry);
// How many accounts it touches, as the Disbursement & Transfers list counts its entries.
row.put("entries", linesFor(row, entry).size());
rows.add(row);
}
return rows;
}
public Map<String, Object> retrieveOne(final Long commandId) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR
if (found.isEmpty()) {
throw new CommandNotFoundException(commandId);
}
final Map<String, Object> row = describe(found.get(0));
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
row.put("lines", linesFor(row, found.get(0)));
return row;
}
/**
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
* are the voucher's own lines; while it waits they are what approving it will move, worked out from
* the request and the accounts as they stand now.
*/
private List<Map<String, Object>> linesFor(final Map<String, Object> row, final Map<String, Object> entry) {
final String voucherNo = (String) row.get("voucherNo");
if (voucherNo != null) {
final List<Map<String, Object>> posted = this.jdbcTemplate.queryForList( // NOSONAR
"select entry_type as \"side\", account_no as \"accountNo\", party_name as \"accountName\", "
+ "case when savings_account_id is not null then 'SAVINGS' when gl_account_id is not null then 'GL' else '' end as \"type\", "
+ "amount from m_voucher_line where voucher_no = ? order by line_no",
voucherNo);
if (!posted.isEmpty()) {
return posted;
}
}
final List<Map<String, Object>> lines = new ArrayList<>();
final String entity = (String) row.get("entity");
final String action = (String) row.get("action");
final Long resourceId = (Long) row.get("resourceId");
final JsonObject json = jsonOf(entry.get("json"));
final BigDecimal amount = (BigDecimal) row.get("amount");
switch (entity + ":" + action) {
case "PARTYTRANSACTION:CREATE" -> {
lines.add(partyLine("DR", text(json, "debitPartyCode"), amount));
lines.add(partyLine("CR", text(json, "creditorPartyCode"), amount));
}
case "PARTY:PAYOUT", "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> {
final String code = first("select party_code from m_dds_party where id = ?", resourceId);
lines.add(partyLine("DR", code, amount));
final String toAccount = text(json, "toSavingsAccountNo");
if (toAccount != null && !toAccount.isBlank()) {
final String owner = first("select c.display_name from m_savings_account s left join m_client c on c.id = s.client_id "
+ "where s.account_no = ?", toAccount);
lines.add(line("CR", toAccount, owner, "SAVINGS", amount));
} else {
final Long glId = longOf(json, "sundryGlAccountId");
final Map<String, Object> gl = glId == null ? Map.of()
: this.jdbcTemplate.queryForList("select gl_code as \"code\", name from acc_gl_account where id = ?", glId) // NOSONAR
.stream().findFirst().orElse(Map.of());
lines.add(line("CR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
}
}
case "DDSCOLLECTIONENTRY:SETTLE" -> {
final Long agentId = longOf(json, "agentId");
final Map<String, Object> agentGl = agentId == null ? Map.of()
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from m_dds_agent a " // NOSONAR
+ "join acc_gl_account g on g.id = a.gl_account_id where a.id = ?", agentId).stream().findFirst().orElse(Map.of());
lines.add(line("DR", (String) agentGl.get("code"), (String) agentGl.get("name"), "GL", amount));
if (agentId != null) {
for (final Map<String, Object> pending : this.jdbcTemplate.queryForList( // NOSONAR
"select p.account_no as \"accountNo\", p.party_code || ' · ' || p.party_name as \"accountName\", e.amount "
+ "from m_dds_collection_entry e join m_dds_party p on p.id = e.party_id "
+ "where e.agent_id = ? and e.status = 'PENDING' order by e.id",
agentId)) {
lines.add(line("CR", (String) pending.get("accountNo"), (String) pending.get("accountName"), "DDS",
(BigDecimal) pending.get("amount")));
}
}
}
case "DDSBATCH:POST" -> {
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = "
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' "
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId);
final Map<String, Object> gl = expense == null ? Map.of()
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of());
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", "
+ "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
+ "case when p.id is null then 'SAVINGS' else 'DDS' end as \"type\", r.amount "
+ "from m_dds_batch_run_row r left join m_dds_party p on p.id = r.party_id "
+ "left join m_dds_agent a on a.id = r.agent_id where r.batch_run_id = ? and r.amount > 0 order by r.id",
resourceId)) {
lines.add(line("CR", (String) credited.get("accountNo"), (String) credited.get("accountName"),
(String) credited.get("type"), (BigDecimal) credited.get("amount")));
}
}
default -> {
// A change to a party, agent or scheme moves no money and has no sides.
}
}
return lines;
}
private Map<String, Object> partyLine(final String side, final String partyCode, final BigDecimal amount) {
final Map<String, Object> party = partyCode == null ? Map.of()
: this.jdbcTemplate.queryForList("select account_no as \"accountNo\", party_name as \"name\" from m_dds_party " // NOSONAR
+ "where party_code = ?", partyCode).stream().findFirst().orElse(Map.of());
return line(side, (String) party.get("accountNo"), partyCode == null ? null : partyCode + " · " + party.get("name"), "DDS", amount);
}
private static Map<String, Object> line(final String side, final String accountNo, final String accountName, final String type,
final BigDecimal amount) {
final Map<String, Object> line = new LinkedHashMap<>();
line.put("side", side);
line.put("accountNo", accountNo);
line.put("accountName", accountName);
line.put("type", type);
line.put("amount", amount);
return line;
}
/** The entry, if it is a DDS entry still waiting on a checker. */
public Map<String, Object> pendingEntry(final Long commandId) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR
if (found.isEmpty()) {
throw new CommandNotFoundException(commandId);
}
if (((Number) found.get(0).get("status")).intValue() != STATUS_AWAITING) {
throw new CommandNotAwaitingApprovalException(commandId);
}
return found.get(0);
}
/** The voucher number held for an entry since it was sent for approval, if any. */
public String heldVoucher(final Long commandId) {
return first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", REF_KEY_PREFIX + commandId);
}
/** Ties the voucher an approval raised to the entry, so the approved list can show it. */
public void linkVoucher(final Long commandId, final String voucherNo) {
if (voucherNo != null) {
this.jdbcTemplate.update("update m_voucher set reference_key = ? where voucher_no = ? and reference_key is null", // NOSONAR
REF_KEY_PREFIX + commandId, voucherNo);
}
}
private static int statusCode(final String status) {
if ("APPROVED".equalsIgnoreCase(status)) {
return STATUS_PROCESSED;
}
return "REJECTED".equalsIgnoreCase(status) ? STATUS_REJECTED : STATUS_AWAITING;
}
private static String statusName(final int code) {
return switch (code) {
case STATUS_PROCESSED -> "APPROVED";
case STATUS_REJECTED -> "REJECTED";
case STATUS_AWAITING -> "PENDING";
default -> "OTHER";
};
}
/** One entry in the clerk's terms: what kind of work, on which accounts, for how much, and where to. */
private Map<String, Object> describe(final Map<String, Object> entry) {
final String entity = (String) entry.get("entity");
final String action = (String) entry.get("action");
final Long resourceId = entry.get("resourceId") == null ? null : ((Number) entry.get("resourceId")).longValue();
final JsonObject json = jsonOf(entry.get("json"));
final int status = ((Number) entry.get("status")).intValue();
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", entry.get("id"));
row.put("entity", entity);
row.put("action", action);
row.put("resourceId", resourceId);
row.put("status", statusName(status));
row.put("maker", entry.get("maker"));
row.put("makerName", entry.get("makerName"));
row.put("checkerName", entry.get("checkerName"));
row.put("makerId", entry.get("makerId"));
row.put("madeOn", entry.get("madeOn"));
row.put("checker", entry.get("checker"));
row.put("checkedOn", entry.get("checkedOn"));
String type = entity + " " + action;
String details = "";
BigDecimal amount = null;
switch (entity + ":" + action) {
case "PARTYTRANSACTION:CREATE" -> {
final String txnType = text(json, "transactionType");
if ("WITHDRAW_DEPOSITE".equals(txnType)) {
type = "Transfer";
details = party(text(json, "debitPartyCode")) + " → " + party(text(json, "creditorPartyCode"));
amount = number(json, "withdrawAmount");
} else {
type = txnType == null ? "DDS Transaction" : txnType.replace('_', ' ');
details = agent(longOf(json, "agentId"));
}
}
case "PARTY:PAYOUT" -> {
type = "Maturity Payout & Close";
details = partyById(resourceId) + " → " + destination(json);
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "MATURITY_PAYOUT");
}
case "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> {
type = "PREMATURECLOSEOVERRIDE".equals(action) ? "Premature Close (within lock-in)" : "Premature Close";
details = partyById(resourceId) + " → " + destination(json);
final String reason = text(json, "lockinOverrideReason");
if (reason != null) {
details = details + " · Reason: " + reason;
}
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "PREMATURE_PAYOUT");
}
case "PARTY:CLOSE" -> {
type = "Close DDS Account";
details = partyById(resourceId);
}
case "PARTY:CREATE" -> {
type = "New DDS Account";
details = resourceId == null ? text(json, "partyName") : partyById(resourceId);
amount = number(json, "dailyDepositAmount");
}
case "PARTY:UPDATE" -> {
type = "Edit DDS Account";
details = partyById(resourceId);
}
case "AGENT:CREATE", "AGENT:UPDATE", "AGENT:CLOSE" -> {
type = switch (action) {
case "CREATE" -> "New Agent";
case "CLOSE" -> "Close Agent";
default -> "Edit Agent";
};
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName"));
}
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
}
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
details = resourceId == null ? "" : "Slab #" + resourceId;
}
case "DDSBATCH:POST" -> {
final Map<String, Object> run = batchRun(resourceId);
type = "Post " + String.valueOf(run.getOrDefault("jobType", "Batch")).replace('_', ' ').toLowerCase();
type = Character.toUpperCase(type.charAt(0)) + type.substring(1);
details = "Run #" + resourceId + " · " + run.getOrDefault("totalRows", 0) + " account(s) · as on " + run.getOrDefault("jobDate", "");
amount = (BigDecimal) run.get("totalAmount");
}
case "DDSCOLLECTIONENTRY:SETTLE" -> {
type = "Apply Receipt";
details = agent(longOf(json, "agentId")) + (text(json, "entryDate") == null ? "" : " · " + text(json, "entryDate"));
// Waiting: what the agent has outstanding. Approved: replaced by the voucher's own figure below.
amount = status == STATUS_AWAITING ? collectionTotal(longOf(json, "agentId"), status) : null;
}
default -> details = "";
}
row.put("type", type);
row.put("details", details);
row.put("amount", amount);
final Long commandId = ((Number) entry.get("id")).longValue();
final String voucherNo = switch (status) {
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
// Numbered when it was sent for approval; a rejected one keeps its number, marked cancelled.
case STATUS_AWAITING -> heldVoucher(commandId);
case STATUS_REJECTED -> first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1",
REF_KEY_PREFIX + commandId + ":REJECTED");
default -> null;
};
row.put("voucherNo", voucherNo);
row.put("voucherCancelled", status == STATUS_REJECTED && voucherNo != null);
if (voucherNo != null && "DDSCOLLECTIONENTRY".equals(entity)) {
// What was actually applied, as the voucher records it.
final List<BigDecimal> applied = this.jdbcTemplate.queryForList("select amount from m_voucher where voucher_no = ?", // NOSONAR
BigDecimal.class, voucherNo);
row.put("amount", applied.isEmpty() ? row.get("amount") : applied.get(0));
}
return row;
}
private String voucherFor(final Long commandId, final String entity, final String action, final Long resourceId, final JsonObject json,
final String madeOn) {
final List<String> linked = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", String.class, REF_KEY_PREFIX + commandId);
if (!linked.isEmpty()) {
return linked.get(0);
}
// Approved without passing through this screen - a user whose work needs no checker, say.
if ("DDSBATCH".equals(entity) && resourceId != null) {
return first("select voucher_no from m_voucher where reference_type = 'DDS_BATCH_RUN' and reference_id = ? order by id desc limit 1",
resourceId);
}
if ("PARTYTRANSACTION".equals(entity) && resourceId != null) {
return first("select voucher_no from m_dds_party_transaction where id = ?", resourceId);
}
if ("DDSCOLLECTIONENTRY".equals(entity) && longOf(json, "agentId") != null && madeOn != null) {
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher where voucher_type = 'DDS_APPLY_RECEIPT' and reference_id = ? "
+ "and created_at >= cast(? as timestamp) - interval '1 minute' order by id limit 1",
String.class, longOf(json, "agentId"), madeOn);
return found.isEmpty() ? null : found.get(0);
}
if ("PARTY".equals(entity) && resourceId != null && ("PAYOUT".equals(action) || action.startsWith("PREMATURECLOSE"))) {
return first("select voucher_no from m_dds_party_transaction where party_id = ? and voucher_no is not null "
+ "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1", resourceId);
}
return null;
}
private String destination(final JsonObject json) {
final String toAccount = text(json, "toSavingsAccountNo");
if (toAccount != null && !toAccount.isBlank()) {
return "Savings " + toAccount;
}
final Long glId = longOf(json, "sundryGlAccountId");
if (glId != null) {
final String gl = first("select gl_code || ' ' || name from acc_gl_account where id = ?", glId);
return "Sundry " + (gl == null ? "#" + glId : gl);
}
return "Sundry";
}
private String party(final String partyCode) {
if (partyCode == null) {
return "";
}
final String name = first("select party_name from m_dds_party where party_code = ?", partyCode);
return name == null ? partyCode : partyCode + " " + name;
}
private String partyById(final Long partyId) {
if (partyId == null) {
return "";
}
final String found = first("select party_code || ' ' || party_name from m_dds_party where id = ?", partyId);
return found == null ? "Party #" + partyId : found;
}
private String agent(final Long agentId) {
if (agentId == null) {
return "";
}
final String found = first("select agent_code || ' ' || coalesce(agent_name, '') from m_dds_agent where id = ?", agentId);
return found == null ? "Agent #" + agentId : found.trim();
}
private String scheme(final Long accountTypeId) {
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
}
private BigDecimal balanceOf(final Long partyId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR
BigDecimal.class, partyId);
return found.isEmpty() ? null : found.get(0);
}
private BigDecimal paidOut(final Long partyId, final String transactionType) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select amount from m_dds_party_transaction where party_id = ? and transaction_type = ? order by id desc limit 1",
BigDecimal.class, partyId, transactionType);
return found.isEmpty() ? null : found.get(0);
}
private BigDecimal collectionTotal(final Long agentId, final int status) {
if (agentId == null) {
return null;
}
final String state = status == STATUS_AWAITING ? "PENDING" : "SETTLED";
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select sum(amount) from m_dds_collection_entry where agent_id = ? and status = ?", BigDecimal.class, agentId, state);
return found.isEmpty() ? null : found.get(0);
}
private Map<String, Object> batchRun(final Long batchRunId) {
if (batchRunId == null) {
return Map.of();
}
return this.jdbcTemplate.queryForList("select job_type as \"jobType\", total_rows as \"totalRows\", total_amount as \"totalAmount\", " // NOSONAR
+ "to_char(job_date, 'DD/MM/YYYY') as \"jobDate\" from m_dds_batch_run where id = ?", batchRunId).stream().findFirst()
.orElse(Map.of());
}
private String first(final String sql, final Object param) {
final List<String> found = this.jdbcTemplate.queryForList(sql, String.class, param); // NOSONAR
return found.isEmpty() ? null : found.get(0);
}
private static JsonObject jsonOf(final Object text) {
if (!(text instanceof String json) || json.isBlank()) {
return new JsonObject();
}
try {
final JsonElement element = JsonParser.parseString(json);
return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
} catch (RuntimeException e) {
return new JsonObject();
}
}
private static String text(final JsonObject json, final String name) {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null;
}
private static BigDecimal number(final JsonObject json, final String name) {
try {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsBigDecimal() : null;
} catch (RuntimeException e) {
return null;
}
}
private static Long longOf(final JsonObject json, final String name) {
try {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsLong() : null;
} catch (RuntimeException e) {
return null;
}
}
}
@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/batchruns")
@Component
@Tag(name = "DDS Batch Runs", description = "The Interest/Commission calculate-then-post batch: Generate previews a run's rows without changing any balance; Post applies exactly those previewed rows. A run can only be posted once.")
@RequiredArgsConstructor
public class BatchRunsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSBATCH";
private final PlatformSecurityContext context;
private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Batch Runs", description = "Returns the list of DDS batch runs, most recent first.")
public Collection<BatchRunData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Batch Run", description = "Returns the details of a batch run, including its previewed/posted rows.")
public BatchRunData retrieveOne(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(batchRunId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType, branchScope (All or Selected), "
+ "jobDate, agentFrom, agentTo\n" + "\n"
+ "jobType is one of four Calculate/Post pairs. A calculation job works out what has been earned since the last one "
+ "and parks it (interest in the Party's accrued-interest figure, commission in the Agent's owed figure); the "
+ "matching posting job moves that parked figure into the customer's balance or out to the agent: "
+ "INTEREST_CALCULATION / INTEREST_POSTING, OD_INTEREST_CALCULATION / OD_INTEREST_POSTING, "
+ "COMMISSION_CALCULATION / COMMISSION_POSTING\n" + "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.generateDdsBatchRun() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post a DDS Batch Run", description = "Applies exactly the rows a prior Generate call previewed: credits "
+ "each row's amount to the Party (interest jobs) or to the Agent's running commission total (commission jobs). "
+ "Fails if the run has already been posted.")
public CommandProcessingResult post(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.postDdsBatchRun(batchRunId) //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(ddsVoucherService.voucherTypeOfRun(batchRunId), DdsVoucherService.REF_BATCH_RUN,
batchRunId, () -> ddsVoucherService.amountOfRun(batchRunId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BatchRunRepository extends JpaRepository<BatchRun, Long> {
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "GENERATE")
public class GenerateBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public GenerateBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.generateBatchRun(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "POST")
public class PostBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.postBatchRun(command.entityId(), command);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class BatchRunCommandFromApiJsonDeserializer {
// The four Calculate/Post pairs from the legacy Interest Part menu. A calculation job works out
// what has been earned and parks it; the matching posting job moves it where it belongs.
//
// Lists, not Sets, so isOneOfTheseStringValues resolves to the overload that lowercases both
// sides - the varargs one lowercases only the submitted value and so never matches these.
public static final List<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 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", "subType", "dateFormat", "locale"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public BatchRunCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForGenerate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsbatchrun");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank()
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES);
final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element);
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES);
if ("Selected".equals(branchScope)) {
v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element))
.jsonArrayNotEmpty();
}
// Not in the future. A run works out what has been earned up to its job date, so a date that
// has not arrived pays interest for days nobody has held their money yet - and worse, the run
// stamps itself as calculated through that date, so the real days are skipped when they come.
final LocalDate jobDate = this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element);
v.reset().parameter("jobDate").value(jobDate).notNull().validateDateBeforeOrEqual(DateUtils.getBusinessLocalDate());
v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull()
.integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists("partyFrom", element)) {
v.reset().parameter("partyFrom").value(this.fromApiJsonHelper.extractLongNamed("partyFrom", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("partyTo", element)) {
v.reset().parameter("partyTo").value(this.fromApiJsonHelper.extractLongNamed("partyTo", element)).ignoreIfNull()
.integerGreaterThanZero();
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
public interface BatchRunReadPlatformService {
Collection<BatchRunData> retrieveAll();
BatchRunData retrieveOne(Long batchRunId);
}
@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunRowData;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final BatchRunMapper BATCH_RUN_MAPPER = new BatchRunMapper();
private static final BatchRunRowMapper ROW_MAPPER = new BatchRunRowMapper();
private static final class BatchRunMapper implements RowMapper<BatchRunData> {
public String schema() {
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.sub_type as subType, b.job_date as jobDate, "
+ " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, "
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, "
+ " b.rows_posted as rowsPosted, b.failure_reason as failureReason, b.total_amount as totalAmount, "
+ " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b";
}
@Override
public BatchRunData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return BatchRunData.builder() //
.id(rs.getLong("id")) //
.jobType(rs.getString("jobType")) //
.branchScope(rs.getString("branchScope")) //
.subType(rs.getString("subType")) //
.jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) //
.agentFrom(rs.getLong("agentFrom")) //
.agentTo(rs.getLong("agentTo")) //
.partyFrom(JdbcSupport.getLong(rs, "partyFrom")) //
.partyTo(JdbcSupport.getLong(rs, "partyTo")) //
.status(rs.getString("status")) //
.totalRows(rs.getInt("totalRows")) //
.rowsPosted(rs.getInt("rowsPosted")) //
.failureReason(rs.getString("failureReason")) //
.totalAmount(rs.getBigDecimal("totalAmount")) //
.generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) //
.postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) //
.rows(new ArrayList<>()) //
.build();
}
}
private static final class BatchRunRowMapper implements RowMapper<Map.Entry<Long, BatchRunRowData>> {
public String schema() {
return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, "
+ " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, "
+ " a.agent_name as agentName, r.amount as amount, r.base_amount as baseAmount, "
+ " r.rate_percent as ratePercent, r.gross_amount as grossAmount, r.tds_amount as tdsAmount, "
+ " r.deduction_amount as deductionAmount, r.from_date as fromDate, r.to_date as toDate "
+ " from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
+ " join m_dds_agent a on a.id = r.agent_id";
}
@Override
public Map.Entry<Long, BatchRunRowData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long batchRunId = rs.getLong("batchRunId");
final BatchRunRowData data = BatchRunRowData.builder() //
.id(rs.getLong("id")) //
.partyId(JdbcSupport.getLong(rs, "partyId")) //
.partyCode(rs.getString("partyCode")) //
.partyName(rs.getString("partyName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.agentName(rs.getString("agentName")) //
.amount(rs.getBigDecimal("amount")) //
.baseAmount(rs.getBigDecimal("baseAmount")) //
.ratePercent(rs.getBigDecimal("ratePercent")) //
.grossAmount(rs.getBigDecimal("grossAmount")) //
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
.deductionAmount(rs.getBigDecimal("deductionAmount")) //
.fromDate(JdbcSupport.getLocalDate(rs, "fromDate")) //
.toDate(JdbcSupport.getLocalDate(rs, "toDate")) //
.build();
return Map.entry(batchRunId, data);
}
}
@Override
public Collection<BatchRunData> retrieveAll() {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " order by b.id desc";
final List<BatchRunData> batchRuns = this.jdbcTemplate.query(sql, BATCH_RUN_MAPPER); // NOSONAR
return attachRows(batchRuns);
}
@Override
public BatchRunData retrieveOne(final Long batchRunId) {
try {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " where b.id = ?";
final BatchRunData batchRun = this.jdbcTemplate.queryForObject(sql, BATCH_RUN_MAPPER, batchRunId); // NOSONAR
return attachRows(List.of(batchRun)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new BatchRunNotFoundException(batchRunId);
}
}
private Collection<BatchRunData> attachRows(final List<BatchRunData> batchRuns) {
if (batchRuns.isEmpty()) {
return batchRuns;
}
final String sql = "select " + ROW_MAPPER.schema() + " order by r.id";
final List<Map.Entry<Long, BatchRunRowData>> rows = this.jdbcTemplate.query(sql, ROW_MAPPER); // NOSONAR
final Map<Long, List<BatchRunRowData>> rowsByBatchRunId = new LinkedHashMap<>();
for (final Map.Entry<Long, BatchRunRowData> row : rows) {
rowsByBatchRunId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<BatchRunData> result = new ArrayList<>(batchRuns.size());
for (final BatchRunData b : batchRuns) {
result.add(b.toBuilder().rows(rowsByBatchRunId.getOrDefault(b.getId(), new ArrayList<>())).build());
}
return result;
}
}
@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.domain.Party;
public interface BatchRunWritePlatformService {
CommandProcessingResult generateBatchRun(JsonCommand command);
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
/**
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting
* day falls on the given date. Driven by the Post DDS Interest scheduled job.
*
* @return how many schemes were posted for - zero on the great majority of days, when nothing is
* due.
*/
int postDueAutoInterest(LocalDate businessDate);
/**
* Brings every matured account's interest up to its own maturity date.
*
* Run nightly beside the scheme postings. Interest otherwise arrives only when a posting run
* happens - monthly, per scheme, and switchable off - so a term ending between two runs leaves
* its last stretch uncredited, and a payout in the meantime pays the customer short.
*
* @return how many accounts were credited; zero on most nights.
*/
int postMaturityInterest(LocalDate businessDate);
/**
* Works out one account's interest up to a date and credits it, returning what was paid.
*
* Called by the nightly pass above, and again by the maturity payout as a second line of defence
* in case that pass never ran. Returns zero, having written nothing, when interest is already
* current.
*/
BigDecimal postInterestThrough(Party party, LocalDate through);
/**
* The interest an account is owed up to a day and has not yet had credited - calculated and left
* unposted, or not calculated at all. Nothing is changed; this only says what a calculation and
* posting run would still credit.
*/
BigDecimal interestNotYetPosted(Party party, LocalDate through);
/**
* Turns over the minors' accounts whose holders have come of age and had their own KYC verified.
*
* Run nightly. The birthday alone does not do it: an eighteen-year-old the bank has not yet
* identified is somebody it cannot pay, so the turnover waits on their KYC - and until then the
* payout refuses while collections carry on as before.
*
* @return how many accounts were turned over; zero on almost every night.
*/
int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate);
/**
* Credits every row on a run and marks it posted. Called from the background thread that
* {@code postBatchRun} hands the work to, not from a request.
*/
void applyAndMarkPosted(Long batchRunId);
/** Records on the run why it stopped, when it did, so a screen can say so rather than only a log. */
void markPostingFailed(Long batchRunId, String reason);
}
@@ -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);
}
}
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.batch.service.DdsBatchPostingRunner;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsBatchConfiguration {
@Bean
@ConditionalOnMissingBean(BatchRunReadPlatformService.class)
public BatchRunReadPlatformService batchRunReadPlatformService(JdbcTemplate jdbcTemplate) {
return new BatchRunReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(BatchRunWritePlatformService.class)
public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer,
BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository,
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository,
DdsAccountTypeRepository ddsAccountTypeRepository, DdsLedger ddsLedger, DdsBatchPostingRunner postingRunner,
JdbcTemplate jdbcTemplate, ObjectProvider<PartyWritePlatformService> partyWritePlatformService) {
return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository,
agentRepository, partyTransactionRepository, commissionSlabRepository, ddsAccountTypeRepository, ddsLedger, postingRunner,
jdbcTemplate, partyWritePlatformService);
}
}
@@ -0,0 +1,158 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/collectionentries")
@Component
@Tag(name = "DDS Collection Entries", description = "What an agent wrote down on their round, before the branch has counted the cash. "
+ "These rows move no money: no customer is credited, no agent's cash recorded and no ledger entry written until a clerk settles "
+ "the agent's day, and what that writes is an ordinary DDS collection.")
@RequiredArgsConstructor
public class CollectionEntriesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSCOLLECTIONENTRY";
private final PlatformSecurityContext context;
private final CollectionEntryReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "An agent's written-down collections for a date", description = "Returns the agent's entries for the date with "
+ "what they add up to. The agent's own screen shows it back as the record of their round; the counter reads the same figures "
+ "to count cash against. Defaults to today when no date is given.")
public AgentCollectionSummaryData retrieveAgentDay(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
@QueryParam("entryDate") @Parameter(description = "entryDate as yyyy-MM-dd") final String entryDate) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final LocalDate date = entryDate == null || entryDate.isBlank() ? DateUtils.getBusinessLocalDate() : LocalDate.parse(entryDate);
return readPlatformService.retrieveAgentDay(agentId, date);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Everything an agent still has outstanding", description = "Every pending entry for the agent, whatever day "
+ "it was written, with what they add up to - what a cashier's DDS receipt is applied against.")
public AgentCollectionSummaryData retrieveAgentPending(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAgentPending(agentId);
}
@GET
@Path("cashreceipts")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Cashier receipts into an agent's GL account", description = "Without voucherNo: the DDS receipts the manager "
+ "has approved and nobody has applied. With voucherNo: that voucher on the agent's account, in whatever state, so the "
+ "screen can say why it cannot be used.")
public List<Map<String, Object>> retrieveAgentCashReceipts(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
@QueryParam("voucherNo") @Parameter(description = "voucherNo") final String voucherNo,
@QueryParam("includeWaiting") @Parameter(description = "also list receipts still awaiting the manager") final Boolean includeWaiting) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAgentCashReceipts(agentId, voucherNo, Boolean.TRUE.equals(includeWaiting));
}
@GET
@Path("cashreceipts/{receiptId}/glentries")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "A cashier receipt's own GL entries", description = "The receipt's Dr/Cr lines, read under its own transaction id.")
public List<Map<String, Object>> retrieveCashReceiptGlEntries(
@PathParam("receiptId") @Parameter(description = "receiptId") final Long receiptId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveCashReceiptGlEntries(receiptId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Write down collections", description = "Mandatory: agentId, entryDate, entries (array of {partyCode, amount, "
+ "transactionMode, chequeNo, tokenNo, description}). Nothing is credited and no cash is recorded - this is the agent's note, "
+ "and it stands on its own until the day is settled.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createDdsCollectionEntries() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, and either entryDate or cashierDepositRequestIds "
+ "with entryDates. With entryDate, posts every outstanding entry for that agent and date as a real collection. With "
+ "cashierDepositRequestIds (approved DDS receipts on the agent's GL account, each used once) and entryDates (yyyy-MM-dd), "
+ "applies the receipts to every outstanding entry on those days - only if they cover them exactly. All or none either way. "
+ "No amount is accepted: what is settled is what the entries say.")
public CommandProcessingResult settle(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.settleDdsCollectionEntries() //
.withJson(apiRequestBodyAsJson) //
.build();
final Long agentId = DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId");
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT, agentId,
() -> ddsVoucherService.amountPendingFor(agentId), () -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
@DELETE
@Path("{entryId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Take back a written-down collection", description = "Cancels an entry that has not been settled. A settled "
+ "entry stands behind a real credit and is reversed on the ledger, not withdrawn here.")
public CommandProcessingResult cancel(@PathParam("entryId") @Parameter(description = "entryId") final Long entryId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.cancelDdsCollectionEntry(entryId) //
.withJson("{}") //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CollectionEntryRepository extends JpaRepository<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 this agent still has outstanding, whatever day it was written on.
*
* The day's cash can reach the counter the day after the round - written on Friday, brought in on
* Saturday - so a receipt is applied against every outstanding line, not one date's. Oldest first,
* the order the money was collected in.
*/
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.status = 'PENDING' order by e.entryDate, e.id")
List<CollectionEntry> findAllPendingFor(@Param("agentId") Long agentId);
/**
* Everything written for this agent on this date, settled or not.
*
* Separate from the pending list because the two answer different questions. A clerk counting
* cash wants only what is outstanding; an agent looking back at their day wants the whole day,
* including what has already been handed in.
*/
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.entryDate = :entryDate order by e.id")
List<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);
/**
* What is written down for this account and not yet settled. Counted with what has been settled
* when a new line is checked against the scheme, so arrears cleared on an entry still waiting at
* the counter cannot be cleared a second time on the next one.
*/
@Query("select coalesce(sum(e.amount), 0) from CollectionEntry e where e.party.id = :partyId and e.status = 'PENDING'")
java.math.BigDecimal sumPendingFor(@Param("partyId") Long partyId);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,204 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* What a written-down collection has to say before it is worth writing down.
*
* Deliberately narrow. This is the agent's own note, not a posting, so the questions asked are only
* the ones that can be answered away from the branch: is there an account named, an amount, and a
* form the money came in. Whether the cash is really there is settled at the counter, and no amount
* of checking here would bring that forward.
*/
@Component
public final class CollectionEntryCommandFromApiJsonDeserializer {
private static final String RESOURCE = "ddscollectionentry";
private static final String ENTRIES = "entries";
private static final Set<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", "advanceDays", "arrearsDays", "transactionMode", "chequeNo", "tokenNo", "description"));
private static final Set<String> SUPPORTED_SETTLE_PARAMETERS = new HashSet<>(
Arrays.asList("agentId", "entryDate", "cashierDepositRequestIds", "entryDates", "dateFormat", "locale"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public CollectionEntryCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<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();
// Days ahead or days of arrears, never both - one line is one kind of collection. The
// amount follows from the days, so it is not required when they are given; where it is
// sent as well it has to agree, which the service checks against the scheme.
final Integer advanceDays = integerOf(row, "advanceDays");
final Integer arrearsDays = integerOf(row, "arrearsDays");
if (advanceDays != null && arrearsDays != null) {
v.reset().parameter(ENTRIES).failWithCodeAndMessage("advance.and.arrears",
"Row " + (i + 1) + ": a line is either days in advance or days of arrears, not both");
}
if (advanceDays != null) {
v.reset().parameter(ENTRIES).parameterAtIndexArray("advanceDays", i).value(advanceDays)
.inMinMaxRange(1, DdsSchedule.MAX_ADVANCE_DAYS);
} else if (arrearsDays != null) {
v.reset().parameter(ENTRIES).parameterAtIndexArray("arrearsDays", i).value(arrearsDays)
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
} else {
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
}
final String mode = stringOf(row, "transactionMode");
v.reset().parameter(ENTRIES).parameterAtIndexArray("transactionMode", i).value(mode).notBlank();
// The row has to be named in the message: a round is entered a line at a time and the
// agent needs to know which line the screen is complaining about.
v.reset().parameter(ENTRIES).value(mode).ignoreIfNull().matchesRegularExpression(DdsTransactionMode.acceptedPattern(),
"Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage());
v.reset().parameter(ENTRIES).parameterAtIndexArray("chequeNo", i).value(stringOf(row, "chequeNo")).ignoreIfNull()
.notExceedingLengthOf(50);
v.reset().parameter(ENTRIES).parameterAtIndexArray("tokenNo", i).value(stringOf(row, "tokenNo")).ignoreIfNull()
.notExceedingLengthOf(50);
v.reset().parameter(ENTRIES).parameterAtIndexArray("description", i).value(stringOf(row, "description")).ignoreIfNull()
.notExceedingLengthOf(200);
}
}
throwIfErrors(errors);
}
public void validateForSettle(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<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();
// The cashier's receipts settle the days picked; without them, one day is named.
if (this.fromApiJsonHelper.parameterExists("cashierDepositRequestIds", element)) {
final var receipts = element.getAsJsonObject().get("cashierDepositRequestIds");
v.reset().parameter("cashierDepositRequestIds").value(receipts != null && receipts.isJsonArray() ? receipts.getAsJsonArray() : null)
.notNull().jsonArrayNotEmpty();
final var days = element.getAsJsonObject().get("entryDates");
v.reset().parameter("entryDates").value(days != null && days.isJsonArray() ? days.getAsJsonArray() : null).notNull()
.jsonArrayNotEmpty();
if (receipts != null && receipts.isJsonArray()) {
for (int i = 0; i < receipts.getAsJsonArray().size(); i++) {
final var id = receipts.getAsJsonArray().get(i);
v.reset().parameter("cashierDepositRequestIds").parameterAtIndexArray("id", i)
.value(id.isJsonPrimitive() && id.getAsJsonPrimitive().isNumber() ? id.getAsLong() : null).notNull()
.integerGreaterThanZero();
}
}
if (days != null && days.isJsonArray()) {
for (int i = 0; i < days.getAsJsonArray().size(); i++) {
final var day = days.getAsJsonArray().get(i);
v.reset().parameter("entryDates").parameterAtIndexArray("date", i)
.value(day.isJsonPrimitive() ? day.getAsString() : null).notBlank()
.matchesRegularExpression("\\d{4}-\\d{2}-\\d{2}", "Dates must be yyyy-MM-dd");
}
}
} else {
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
}
// No amount is asked for. What is settled is what the entries say, to the rupee - a figure
// typed here would be a second opinion about a total the entries already give, and the two
// could differ.
throwIfErrors(errors);
}
private String stringOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Integer integerOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt()
: null;
}
private BigDecimal decimalOf(final JsonObject json) {
return json.has("amount") && !json.get("amount").isJsonNull() ? json.get("amount").getAsBigDecimal() : null;
}
private void throwIfErrors(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
public interface CollectionEntryReadPlatformService {
/**
* An agent's written-down collections for a date, with what they add up to.
*
* One call serves both screens. The agent's own page shows it back to them as the running record
* of their round; the clerk's settlement screen reads the same figures to count cash against.
* Two calls would be two chances for the two screens to disagree about the same day.
*/
AgentCollectionSummaryData retrieveAgentDay(Long agentId, LocalDate entryDate);
/** Everything the agent still has outstanding, whatever day it was written. */
AgentCollectionSummaryData retrieveAgentPending(Long agentId);
/** The cashier's receipts into the agent's GL account - the applicable ones, or one voucher by number. */
List<Map<String, Object>> retrieveAgentCashReceipts(Long agentId, String voucherNo, boolean includeWaiting);
/** A cashier's receipt's own Dr/Cr lines. */
List<Map<String, Object>> retrieveCashReceiptGlEntries(Long receiptId);
}
@@ -0,0 +1,207 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.service;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
import org.apache.fineract.portfolio.dds.collection.data.CollectionEntryData;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final AgentRepository agentRepository;
private static final class EntryMapper implements RowMapper<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, "
+ " e.advance_days as advanceDays, e.arrears_days as arrearsDays "
+ " from m_dds_collection_entry e "
+ " join m_dds_agent a on a.id = e.agent_id "
+ " join m_dds_party p on p.id = e.party_id";
}
@Override
public CollectionEntryData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return CollectionEntryData.builder() //
.id(rs.getLong("id")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.agentName(rs.getString("agentName")) //
.partyId(rs.getLong("partyId")) //
.partyCode(rs.getString("partyCode")) //
.partyName(rs.getString("partyName")) //
.partyBalance(rs.getBigDecimal("partyBalance")) //
.dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) //
.entryDate(JdbcSupport.getLocalDate(rs, "entryDate")) //
.amount(rs.getBigDecimal("amount")) //
.transactionMode(rs.getString("transactionMode")) //
.chequeNo(rs.getString("chequeNo")) //
.tokenNo(rs.getString("tokenNo")) //
.description(rs.getString("description")) //
.status(rs.getString("status")) //
.settledOn(JdbcSupport.getLocalDate(rs, "settledOn")) //
.partyTransactionId(JdbcSupport.getLong(rs, "partyTransactionId")) //
.advanceDays(JdbcSupport.getInteger(rs, "advanceDays")) //
.arrearsDays(JdbcSupport.getInteger(rs, "arrearsDays")) //
.build();
}
}
/**
* Everything the agent still has outstanding, whatever day it was written - what a cashier's
* receipt is applied against. Same shape as a day, with no date on it.
*/
@Override
public AgentCollectionSummaryData retrieveAgentPending(final Long agentId) {
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final EntryMapper mapper = new EntryMapper();
final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.status = '" + CollectionEntry.PENDING + "' "
+ " order by e.entry_date, e.id";
final List<CollectionEntryData> entries = this.jdbcTemplate.query(sql, mapper, agentId);
final BigDecimal expected = entries.stream().map(CollectionEntryData::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
return AgentCollectionSummaryData.builder() //
.agentId(agent.getId()) //
.agentCode(agent.getAgentCode()) //
.agentName(agent.getAgentName()) //
.pendingCount(entries.size()) //
.expectedAmount(expected) //
.settledCount(0) //
.settledAmount(BigDecimal.ZERO) //
.entries(entries) //
.build();
}
/**
* The cashier's receipts into this agent's GL account.
*
* Without a voucher number: only what can be applied - DDS receipts the manager has approved and
* nobody has applied yet - or, asked to include waiting ones, those still with the manager as well,
* so the counter can see cash that has come in but cannot be applied yet. With one: that voucher on
* this account whatever state it is in, so the screen can say why it cannot be used rather than
* simply not finding it. Read straight off the cashier's own table and never written.
*/
@Override
public List<Map<String, Object>> retrieveAgentCashReceipts(final Long agentId, final String voucherNo, final boolean includeWaiting) {
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
if (agent.getGlAccount() == null) {
return List.of();
}
final StringBuilder sql = new StringBuilder("select d.id as \"id\", d.voucher_no as \"voucherNo\", "
+ " to_char(d.txn_date, 'YYYY-MM-DD') as \"txnDate\", d.amount as \"amount\", d.status as \"status\", "
+ " cv.code_value as \"entryType\", d.account_no as \"glCode\", g.name as \"glName\", "
+ " d.cashier_name as \"cashierName\", d.manager_name as \"managerName\", d.description as \"description\", "
+ " d.txn_id as \"txnId\", "
+ " exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) as \"applied\" "
+ " from m_cashier_deposit_request d "
+ " left join m_code_value cv on cv.id = d.entry_type_cv_id "
+ " left join acc_gl_account g on g.id = d.gl_account_id "
+ " where trim(d.account_no) = ? ");
final Object[] params;
if (voucherNo != null && !voucherNo.isBlank()) {
sql.append(" and d.voucher_no = ? ");
params = new Object[] { agent.getGlAccount().getGlCode(), voucherNo.trim() };
} else {
sql.append(" and cv.code_value = 'DDS' ")
.append(includeWaiting ? " and d.status not in ('REJECTED', 'CANCELLED') " : " and d.status = 'APPROVED' ")
.append(" and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) ");
params = new Object[] { agent.getGlAccount().getGlCode() };
}
sql.append(" order by d.txn_date, d.id");
return this.jdbcTemplate.queryForList(sql.toString(), params); // NOSONAR
}
/** A receipt's own Dr/Cr lines, read under its own transaction id. */
@Override
public List<Map<String, Object>> retrieveCashReceiptGlEntries(final Long receiptId) {
return this.jdbcTemplate.queryForList("select je.id as \"id\", g.gl_code as \"glCode\", g.name as \"glName\", " // NOSONAR
+ " case je.type_enum when 1 then 'CR' else 'DR' end as \"drCr\", je.amount as \"amount\", "
+ " to_char(je.entry_date, 'YYYY-MM-DD') as \"entryDate\", je.reversed as \"reversed\" "
+ " from acc_gl_journal_entry je join acc_gl_account g on g.id = je.account_id "
+ " where je.transaction_id = (select d.txn_id from m_cashier_deposit_request d where d.id = ?) order by je.id", receiptId);
}
@Override
public AgentCollectionSummaryData retrieveAgentDay(final Long agentId, final LocalDate entryDate) {
// Named first, so an agent id that matches nothing is refused here rather than quietly
// returning an empty day - which reads as "collected nothing", and that is an answer a clerk
// would act on.
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final EntryMapper mapper = new EntryMapper();
// Cancelled lines are left out. They were taken back before anything was posted, so they are
// not part of the day's record and would only be counted by somebody adding the column up.
final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.entry_date = ? "
+ " and e.status <> '" + CollectionEntry.CANCELLED + "' order by e.id";
final List<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();
}
}
@@ -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);
}
@@ -0,0 +1,476 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotPendingException;
import org.apache.fineract.portfolio.dds.collection.exception.NothingToSettleException;
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyNotCollectedByAgentException;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
import org.springframework.transaction.annotation.Transactional;
/**
* Writing down collections, taking them back, and turning a day's worth into real credits.
*
* The division of labour here is the whole point of the feature. Everything up to the settlement
* writes rows in one table and touches nothing else - no balance, no ledger, no agent's cash. The
* settlement is where it becomes the bank's business, and what it does there is not its own version
* of a posting but the same one the counter has always used.
*/
@RequiredArgsConstructor
public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements CollectionEntryWritePlatformService {
private final CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final CollectionEntryRepository collectionEntryRepository;
private final AgentRepository agentRepository;
private final PartyRepository partyRepository;
private final PartyTransactionWritePlatformService partyTransactionWritePlatformService;
private final PartyTransactionRepository partyTransactionRepository;
private final DdsLedger ddsLedger;
private final GLAccountRepository glAccountRepository;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Transactional
@Override
public CommandProcessingResult createEntries(final JsonCommand command) {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
// A round is written on the day, or caught up on afterwards - never ahead of it.
if (entryDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw refused("entry.future.date", "A collection cannot be written down for " + entryDate + ", which has not happened yet.");
}
final JsonArray rows = command.arrayOfParameterNamed("entries");
final List<Long> written = new ArrayList<>(rows.size());
final Set<Long> partiesInRound = new HashSet<>();
BigDecimal total = BigDecimal.ZERO;
for (int i = 0; i < rows.size(); i++) {
final JsonObject row = rows.get(i).getAsJsonObject();
final Party party = collectableParty(stringOf(row, "partyCode"), agent);
// One line per account per day. A daily scheme takes one instalment a day, so a second line
// is nearly always the same collection entered twice; more than a day's money is one line
// with days in advance or days of arrears on it. Refused here, not only on the agent's
// screen, because the screen is not the only way in.
if (!partiesInRound.add(party.getId()) || this.collectionEntryRepository.countLiveEntriesFor(party.getId(), entryDate) > 0) {
throw refused("entry.duplicate", party.getPartyCode() + " already has a collection written down for " + entryDate
+ ". One line per account per day - use Advance or Previous days to cover more than one day.");
}
final Integer advanceDays = integerOf(row, "advanceDays");
final Integer arrearsDays = integerOf(row, "arrearsDays");
final BigDecimal amount = checkedAmount(party, entryDate, row, advanceDays, arrearsDays);
final CollectionEntry entry = new CollectionEntry(agent, party, entryDate, amount,
DdsTransactionMode.canonicalOrOriginal(stringOf(row, "transactionMode")), stringOf(row, "chequeNo"),
stringOf(row, "tokenNo"), stringOf(row, "description"));
entry.covering(advanceDays, arrearsDays);
written.add(this.collectionEntryRepository.saveAndFlush(entry).getId());
total = total.add(amount);
}
final Map<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());
// One DDS voucher for the day applied: the agent's account on one side, each customer credited on the other.
return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT,
command.longValueOfParameterNamed("agentId"), () -> settleWithinVoucher(command));
}
private CommandProcessingResult settleWithinVoucher(final JsonCommand command) {
// Named by the cashier's receipts: the cash has already reached the bank and sits on the agent's
// GL account, and settling hands it on from there. Without them, the day is settled the way it
// always was.
if (command.parameterExists(RECEIPT_IDS)) {
return settleAgainstReceipts(command);
}
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
final List<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();
}
private static final String RECEIPT_IDS = "cashierDepositRequestIds";
private static final String ENTRY_DATES = "entryDates";
/**
* A receipt as the cashier left it, locked for the length of this settlement so the same voucher
* cannot be applied twice by two clerks at once. Read only - the cashier's row is never written here.
*/
private static final String RECEIPT_SQL = "select d.id, d.voucher_no, d.account_no, d.amount, d.status, d.gl_account_id, "
+ " (select cv.code_value from m_code_value cv where cv.id = d.entry_type_cv_id) as entry_type "
+ " from m_cashier_deposit_request d where d.id = ? for update";
private static final String RECEIPT_USED_SQL = "select count(*) from m_dds_receipt_application_voucher where cashier_deposit_request_id = ?";
private static final String INSERT_APPLICATION_SQL = "insert into m_dds_receipt_application "
+ " (agent_id, applied_on, total_amount, entry_count, gl_account_id, journal_transaction_id, createdby_id, created_date) "
+ " values (?, ?, ?, ?, ?, null, ?, current_timestamp) returning id";
private static final String INSERT_APPLICATION_VOUCHER_SQL = "insert into m_dds_receipt_application_voucher "
+ " (application_id, cashier_deposit_request_id, voucher_no, amount) values (?, ?, ?, ?)";
private static final String SET_JOURNAL_SQL = "update m_dds_receipt_application set journal_transaction_id = ? where id = ?";
/**
* Applies the cashier's DDS receipts to the agent's entries for the days picked.
*
* The same as Apply on a Share Collection receipt, for any number of vouchers and days: a day's cash
* can arrive in two visits, and two days' in one. Every voucher has to be the agent's - a DDS
* receipt, approved, into this agent's own GL account - and applied once. Together they have to
* cover the picked days' entries exactly: what the agent wrote down is what the customers are owed,
* so vouchers short of it leave somebody uncredited and vouchers over it leave money on the agent's
* account nobody can place. All of it or none.
*/
private CommandProcessingResult settleAgainstReceipts(final JsonCommand command) {
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final GLAccount agentAccount = agent.getGlAccount();
if (agentAccount == null) {
throw refused("agent.no.gl", agent.getAgentCode() + " has no GL account, so no cashier receipt can be theirs.");
}
// The vouchers.
final JsonArray receiptIds = command.arrayOfParameterNamed(RECEIPT_IDS);
final Set<Long> seen = new LinkedHashSet<>();
final List<Map<String, Object>> receipts = new ArrayList<>();
BigDecimal received = BigDecimal.ZERO;
Long receiptAccountId = null;
for (int i = 0; i < receiptIds.size(); i++) {
final Long receiptId = receiptIds.get(i).getAsLong();
if (!seen.add(receiptId)) {
continue;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(RECEIPT_SQL, receiptId); // NOSONAR
if (rows.isEmpty()) {
throw refused("receipt.not.found", "No cashier receipt with id " + receiptId + ".");
}
final Map<String, Object> receipt = rows.get(0);
final String voucherNo = (String) receipt.get("voucher_no");
if (!"DDS".equals(receipt.get("entry_type"))) {
throw refused("receipt.wrong.purpose", "Voucher " + voucherNo + " was not taken as a DDS receipt. Ask the cashier to "
+ "correct the entry type at Cashier > Transaction Entry.");
}
if (!"APPROVED".equals(receipt.get("status"))) {
throw refused("receipt.not.approved", "Voucher " + voucherNo + " is " + receipt.get("status")
+ ". It can be applied once the manager has approved it.");
}
if (!agentAccount.getGlCode().equals(String.valueOf(receipt.get("account_no")).trim())) {
throw refused("receipt.other.account", "Voucher " + voucherNo + " was paid into " + receipt.get("account_no") + ", not "
+ agent.getAgentCode() + "'s account " + agentAccount.getGlCode() + ".");
}
final Long used = this.jdbcTemplate.queryForObject(RECEIPT_USED_SQL, Long.class, receiptId); // NOSONAR
if (used != null && used > 0) {
throw refused("receipt.already.applied", "Voucher " + voucherNo + " has already been applied.");
}
final Long accountId = receipt.get("gl_account_id") == null ? agentAccount.getId()
: ((Number) receipt.get("gl_account_id")).longValue();
if (receiptAccountId != null && !receiptAccountId.equals(accountId)) {
throw refused("receipt.mixed.accounts", "The vouchers picked were paid into different GL accounts. Apply them separately.");
}
receiptAccountId = accountId;
received = received.add((BigDecimal) receipt.get("amount"));
receipts.add(receipt);
}
// The entries: everything outstanding on the days picked.
final JsonArray dateArray = command.arrayOfParameterNamed(ENTRY_DATES);
final Set<LocalDate> dates = new LinkedHashSet<>();
for (int i = 0; i < dateArray.size(); i++) {
dates.add(LocalDate.parse(dateArray.get(i).getAsString()));
}
final List<CollectionEntry> pending = new ArrayList<>();
for (final CollectionEntry entry : this.collectionEntryRepository.findAllPendingFor(agent.getId())) {
if (dates.contains(entry.getEntryDate())) {
pending.add(entry);
}
}
if (pending.isEmpty()) {
throw new NothingToSettleException(agent.getAgentCode(), dates.toString());
}
final BigDecimal expected = pending.stream().map(CollectionEntry::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (expected.compareTo(received) != 0) {
throw refused("receipt.amount.mismatch", "The vouchers come to " + received.stripTrailingZeros().toPlainString() + " but "
+ agent.getAgentCode() + "'s entries for the days picked come to " + expected.stripTrailingZeros().toPlainString()
+ ". They have to match exactly.");
}
// Debited from the very account the vouchers credited, not whatever the agent names today.
final GLAccount receiptAccount = this.glAccountRepository.findById(receiptAccountId).orElse(agentAccount);
// The application, then the money.
final LocalDate settledOn = DateUtils.getBusinessLocalDate();
final Long applicationId = this.jdbcTemplate.queryForObject(INSERT_APPLICATION_SQL, Long.class, agent.getId(), // NOSONAR
java.sql.Date.valueOf(settledOn), expected, pending.size(), receiptAccount.getId(),
this.context.authenticatedUser().getId());
final List<String> voucherNos = new ArrayList<>();
for (final Map<String, Object> receipt : receipts) {
// The unique constraint on the voucher is the last word: a second clerk racing this one
// fails here rather than applying the same cash twice.
this.jdbcTemplate.update(INSERT_APPLICATION_VOUCHER_SQL, applicationId, receipt.get("id"), receipt.get("voucher_no"), // NOSONAR
receipt.get("amount"));
voucherNos.add((String) receipt.get("voucher_no"));
}
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
BigDecimal pooled = BigDecimal.ZERO;
for (final CollectionEntry entry : pending) {
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
if (party.getSavingsAccountId() != null) {
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
} else {
pooled = pooled.add(entry.getAmount());
}
// The customer's own statement records the day the money was collected, as it always has.
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
// carried.
final BigDecimal balanceAfter = party.credit(entry.getAmount());
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
this.collectionEntryRepository.save(entry);
}
// Everyone on DDS's own accounts, in one pair.
final String journalId = "DDSR-A" + applicationId;
if (pooled.signum() > 0) {
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description);
this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR
}
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put("applicationId", applicationId);
changes.put("entriesSettled", pending.size());
changes.put("totalAmount", expected);
changes.put("settledOn", settledOn);
changes.put("vouchers", voucherNos);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
}
/**
* What one line collects, checked against where the account stands on the day it is written for.
*
* The bank's rules, the same ones the counter's own collection applies (DdsSchedule):
* <ul>
* <li>an ordinary line is today's instalment - less if the customer paid less, never more;</li>
* <li>days in advance, one to five, and the account may not end up more than five days ahead;</li>
* <li>days of arrears, one to three, and only as many as are still inside the three-day catch-up
* window - older missed days are skipped, and skipped means gone.</li>
* </ul>
* With days, the amount is the daily instalment times them, worked out here rather than trusted;
* one sent as well has to agree. What is already written down and still waiting at the counter is
* counted with what has been settled, so arrears cleared on one pending line cannot be cleared again
* on the next.
*/
private BigDecimal checkedAmount(final Party party, final LocalDate entryDate, final JsonObject row, final Integer advanceDays,
final Integer arrearsDays) {
final BigDecimal daily = party.getDailyDepositAmount();
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
throw new PartyHasNoDailyAmountException(party.getPartyCode());
}
final int due = DdsSchedule.instalmentsDue(party.getOpeningDate(), party.getDays(), entryDate);
final BigDecimal collected = this.partyTransactionRepository.sumCollectedFor(party.getId())
.add(this.collectionEntryRepository.sumPendingFor(party.getId()));
final DdsSchedule before = DdsSchedule.of(daily, due, collected);
final BigDecimal amount;
if (advanceDays != null) {
amount = daily.multiply(BigDecimal.valueOf(advanceDays));
} else if (arrearsDays != null) {
final int allowed = before.recoverableDays(daily);
if (arrearsDays > allowed) {
throw new PartyArrearsDaysExceededException(party.getPartyCode(), arrearsDays, allowed);
}
amount = daily.multiply(BigDecimal.valueOf(arrearsDays));
} else {
amount = row.get("amount").getAsBigDecimal();
if (amount.compareTo(daily) > 0) {
throw refused("entry.more.than.a.day", party.getPartyCode() + ": " + amount.stripTrailingZeros().toPlainString()
+ " is more than one day's instalment of " + daily.stripTrailingZeros().toPlainString()
+ ". Use Advance for days ahead or Previous days for days missed.");
}
}
final Integer days = advanceDays != null ? advanceDays : arrearsDays;
if (days != null && row.has("amount") && !row.get("amount").isJsonNull() && !row.get("amount").getAsString().isBlank()) {
final BigDecimal quoted = row.get("amount").getAsBigDecimal();
if (quoted.compareTo(amount) != 0) {
throw new PartyAdvanceAmountMismatchException(party.getPartyCode(), days, amount, quoted, advanceDays != null);
}
}
// Whatever kind of line it is, the account may not end up more than five days ahead.
final DdsSchedule after = DdsSchedule.of(daily, due, collected.add(amount));
if (after.advanceDays() > DdsSchedule.MAX_ADVANCE_DAYS) {
throw refused("entry.advance.exceeded", party.getPartyCode() + " would be " + after.advanceDays()
+ " days ahead after this; an account may be at most " + DdsSchedule.MAX_ADVANCE_DAYS + " days ahead.");
}
return amount;
}
private Integer integerOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt()
: null;
}
private PlatformDataIntegrityException refused(final String code, final String message) {
return new PlatformDataIntegrityException("error.msg.dds.collection." + code, message);
}
/**
* The account this line names, if this agent may collect from it at all.
*
* The same three questions the counter asks, asked here as well - once when the agent writes the
* line down and again when the clerk settles it. Asking early is what lets an agent be told at
* the customer's door rather than hours later at the branch; asking again is what stops a line
* written this morning being posted this evening onto an account that has since been closed.
*/
/** Named rather than found empty-handed: an id that matches no agent is a caller's mistake, not a day with nothing in it. */
private Agent agentOf(final Long agentId) {
return this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
}
private Party collectableParty(final String partyCode, final Agent agent) {
final Party party = this.partyRepository.findByPartyCode(partyCode).orElseThrow(() -> new PartyNotFoundException(partyCode));
if (party.isClosed()) {
throw new PartyAlreadyClosedException(partyCode);
}
if (!party.getAgent().getId().equals(agent.getId())) {
throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode());
}
return party;
}
private String stringOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.collection.starter;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformService;
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsCollectionConfiguration {
@Bean
@ConditionalOnMissingBean(CollectionEntryReadPlatformService.class)
public CollectionEntryReadPlatformService collectionEntryReadPlatformService(JdbcTemplate jdbcTemplate,
AgentRepository agentRepository) {
return new CollectionEntryReadPlatformServiceImpl(jdbcTemplate, agentRepository);
}
@Bean
@ConditionalOnMissingBean(CollectionEntryWritePlatformService.class)
public CollectionEntryWritePlatformService collectionEntryWritePlatformService(
CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer, CollectionEntryRepository collectionEntryRepository,
AgentRepository agentRepository, PartyRepository partyRepository,
PartyTransactionWritePlatformService partyTransactionWritePlatformService, PartyTransactionRepository partyTransactionRepository,
DdsLedger ddsLedger, GLAccountRepository glAccountRepository, JdbcTemplate jdbcTemplate, PlatformSecurityContext context) {
return new CollectionEntryWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, collectionEntryRepository,
agentRepository, partyRepository, partyTransactionWritePlatformService, partyTransactionRepository, ddsLedger,
glAccountRepository, jdbcTemplate, context);
}
}
@@ -0,0 +1,255 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/parties")
@Component
@Tag(name = "DDS Parties", description = "A DDS Party is a customer's daily-deposit scheme account, linked to the Agent who collects for it. Carries the scheme's KYC, terms, two-stage overdue flags and a nominee list.")
@RequiredArgsConstructor
public class PartiesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTY";
private final PlatformSecurityContext context;
private final PartyReadPlatformService readPlatformService;
private final DdsLedger ddsLedger;
private final DdsVoucherService ddsVoucherService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
/**
* Called directly for the one-off deposit-account catch-up. That is a data fix rather than an
* everyday action, so it takes no request body and does not need the command-source machinery -
* the account it opens and the entries it writes are their own record.
*/
private final PartyWritePlatformService partyWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties. "
+ "Pass agentId to get only the parties a given agent collects for - what the daily "
+ "transaction screen needs, since it works one agent at a time.")
public Collection<PartyData> retrieveAll(
@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll(agentId);
}
@GET
@Path("dueforclosure")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "DDS accounts due for closure", description = "The accounts whose term has run out and which are still "
+ "open - the branch's list of closures waiting to be done, oldest maturity first.\n" + "\n"
+ "Nothing closes itself. A matured account is money the bank now owes the customer, and where it goes has to be "
+ "agreed with them, so it stays on this list until a clerk deals with it - in two steps, paying it out and then "
+ "closing it. An account still carrying a balance is waiting on the payment; one showing nil has been paid and "
+ "is waiting on the close.")
public Collection<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
@Path("sundryaccounts")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Sundry accounts a DDS balance may be parked in",
description = "For a customer with no account here: every open posting liability account that is not a customer "
+ "or agent control account, by GL code, for the clerk to search and pick their bank's Sundry. The same "
+ "list command=payout and command=closeEarly accept as sundryGlAccountId (required when parking). "
+ "Returns id, glCode and name.")
public List<Map<String, Object>> retrieveSundryAccounts() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return ddsLedger.sundryAccounts();
}
@GET
@Path("{partyId}/closure")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "How a closed DDS account was closed",
description = "For a closed account: the kind of closure (PREMATURE, MATURITY_PAYOUT_AND_CLOSE, MATURITY_AUTO_CLOSE, "
+ "MATURITY_PAYOUT_THEN_CLOSE, CLOSED_NIL_BALANCE), who closed and approved it, the lock-in reason, the balance, "
+ "interest recovered or topped up, the agent's penalty, what was paid out and where it went - the customer's "
+ "account or the Sundry GL - with both ledger legs. For an open account returns closed=false.")
public Map<String, Object> retrieveClosure(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveClosure(partyId);
}
@GET
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Party", description = "Returns the details of a DDS party, including its nominees.")
public PartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, "
+ "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n"
+ "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, "
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, "
+ "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, "
+ "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, "
+ "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, "
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)\n" + "\n"
+ "maturityDate and maturityAmount are not accepted: both are worked out from openingDate, month/days, "
+ "dailyDepositAmount and interestRate, and are returned on read.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createParty() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Party", description = "Updates the details of a DDS party.")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Act on a DDS Party", description = "command=close closes the party - its balance must already be "
+ "zero (settled with command=payout, or with a full Withdraw Deposite) and closeDate is mandatory.\n" + "\n"
+ "command=opendepositaccount opens the real deposit account of a party created before DDS began opening them, "
+ "and brings its existing balance onto that account. Run once per such party; refused for a party that already "
+ "has an account. No request body.\n" + "\n"
+ "command=payout pays a matured account out and leaves it open with a nil balance, for command=close to be run "
+ "against separately once the clerk has seen the money land. What is paid is what the account actually holds - "
+ "the deposits plus the interest already posted - not the figure quoted at opening. Optional field: "
+ "toSavingsAccountNo, another open savings account of the same customer, which the money is moved into. Omit it "
+ "when the customer has no other account here: the money is then parked in the matured-deposits payable account "
+ "for the branch to send on to their account at another bank. Refused before the maturity date, and refused on "
+ "an account with nothing left to pay - that one needs closing, not paying.\n" + "\n"
+ "command=prematureclose closes an account before its term is up and pays out what is left, in one act - "
+ "unlike payout, which leaves the account open. Mandatory field: shortfallBorneBy, either CUSTOMER (their "
+ "interest is recalculated at the scheme's reduced rate and the difference taken back) or AGENT (the customer "
+ "keeps a full month's interest and the agent funds the days left in it). The agent is charged a share of "
+ "their commission either way. Optional field: toSavingsAccountNo, as for payout. The lock-in period, the "
+ "rate reduction and the agent's share are the scheme's own and are read as the account is closed, so terms "
+ "the bank changes take effect on accounts already open. Refused inside the lock-in period, and refused on an "
+ "account that has already matured - that one is paid out and closed in the ordinary way.\n" + "\n"
+ "command=prematurecloseoverride is the same early closure for one account still inside its lock-in. It takes "
+ "the same fields plus mandatory lockinOverrideReason, runs under its own permission so it goes to a manager "
+ "for approval, and leaves the scheme's lock-in unchanged for every other account.")
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@QueryParam("command") @Parameter(description = "command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if ("opendepositaccount".equals(commandParam)) {
return this.partyWritePlatformService.openDepositAccount(partyId);
}
if ("payout".equals(commandParam)) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.payOutParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if ("prematureclose".equals(commandParam)) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.prematureCloseParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if ("prematurecloseoverride".equals(commandParam)) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.prematureCloseOverrideParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if (!"close".equals(commandParam)) {
throw new UnrecognizedQueryParamException("command", commandParam);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.closeParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,134 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PartyRepository extends JpaRepository<Party, Long> {
/**
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
* "long" type - the cast has to name a database type.
*/
@Query(value = "select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party "
+ "where party_code like 'PT%'", nativeQuery = true)
Long retrieveMaxPartyCodeSuffix();
Optional<Party> findByPartyCode(String partyCode);
/**
* Open accounts still assigned to this agent. Nobody collects for an agent who has been switched
* off, so these would simply stop being collected from without anyone being told.
*/
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'")
Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
/** Open accounts on a scheme, which take its rate when the rate is changed. */
@Query("select p from Party p where p.subType = :subType and p.status = 'ACTIVE'")
List<Party> findActiveOnSubType(@Param("subType") String subType);
/**
* Accounts an interest run covers. Closed accounts are excluded: a closed account has been paid
* out and settled to zero, so accruing further interest on it would credit an account nobody can
* draw on and put the DDS balances out of step with what was actually paid.
*/
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)")
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
+ "and (p.od1Flag = true or p.od2Flag = true)")
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
/**
* Accounts holding interest that has been calculated but not yet credited - what an Interest
* Posting run has to pay in. Accounts holding nothing are left out rather than previewed as zero
* rows.
*/
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
List<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);
}
@@ -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.");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CLOSE")
public class ClosePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public ClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.closeParty(command.entityId(), command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CREATE")
public class CreatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public CreatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createParty(command);
}
}
@@ -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);
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes one DDS account early inside its scheme's lock-in.
*
* Its own command rather than a flag on PREMATURECLOSE, so waiving a lock-in has its own permission and
* its own maker-checker queue: the clerk asks, with a reason, and a manager decides. The scheme's
* lock-in is never touched to let one account through.
*/
@Service
@CommandType(entity = "PARTY", action = "PREMATURECLOSEOVERRIDE")
public class PrematureCloseOverridePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public PrematureCloseOverridePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.prematureCloseWithinLockin(command.entityId(), command);
}
}
@@ -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);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "UPDATE")
public class UpdatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public UpdatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateParty(command.entityId(), command);
}
}
@@ -0,0 +1,645 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.dds.party.domain.PartyNominee;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class PartyCommandFromApiJsonDeserializer {
private static final Set<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",
// maturityDate and maturityAmount are deliberately absent: the Party works both out from
// the opening date and the terms, so a caller supplying them is refused rather than
// silently ignored.
"minorDate", "guardianClientId", "minAccountOpt", "relation", "openingDate", "month", "reopenDate",
"depositAmount", "dailyDepositAmount", "days", "ledgerNo", "interestRate", "withdrawType", "lien", "payDate",
"paymentAmount", "openingBalance", "isSmsActive", "isEmailActive", "interestDate", "spDescription", "freezeCode", "advCode",
"creditToBranch", "creditToAccountType", "creditToAccount", "nomineeApplicable", "nomineeSplitMode", "nominees",
"savingsAccountNo", "officeId", "lienReason", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_NOMINEE_PARAMETERS = new HashSet<>(
Arrays.asList("nominationDate", "name", "mobileNo", "birthDate", "minor", "relation", "gender", "guardianName",
"sharePercent", "sequenceNo"));
/**
* The array the nominees sit in. Named on every error raised against one, because the validator
* builds "nominees[0][name]" from the array name and the field name together - a field named
* without its array reads back as "null[0][name]".
*/
private static final String NOMINEES = "nominees";
/** The split that states a share per nominee; the other, Sequence, states a place in the order. */
private static final String PERCENTAGE_SPLIT = "Percentage";
private static final String SEQUENCE_SPLIT = "Sequence";
/** What those shares have to come to between them - the whole deposit, not a part of it. */
private static final BigDecimal HUNDRED_PERCENT = BigDecimal.valueOf(100);
/*
* What each identifier has to look like.
*
* Length alone was all these carried, so a mobile number could be typed as letters and a PAN as
* anything ten characters long. None of it shows until somebody tries to use it - an SMS that
* never arrives, a PAN the tax portal rejects - by which time the customer is not in the branch
* any more.
*
* Aadhaar and PAN follow the patterns CBS already applies on the Client Family Member screen,
* rather than a second opinion of the same thing.
*/
private static final Pattern MOBILE_PATTERN = Pattern.compile("^[6-9][0-9]{9}$");
/** Landlines vary; what they are not is letters. STD codes and separators are allowed through. */
private static final Pattern TELEPHONE_PATTERN = Pattern.compile("^[0-9+()\\-\\s]{6,20}$");
private static final Pattern PINCODE_PATTERN = Pattern.compile("^[1-9][0-9]{5}$");
/**
* Twelve digits, or the masked form CKYC hands back - the last four digits with the rest as Xs.
* A record taken from CKYC arrives masked by design, and refusing it would refuse the bank's own
* verified answer.
*/
private static final Pattern AADHAAR_PATTERN = Pattern.compile("^([0-9]{12}|[Xx]{8}[0-9]{4})$");
private static final Pattern PAN_PATTERN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$");
private static final Pattern CKYC_PATTERN = Pattern.compile("^[0-9]{14}$");
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public PartyCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
// The branch the account belongs to, asked for rather than taken from the collecting agent:
// an agent of one branch may hold accounts for customers of another, and an account that
// silently inherits its agent's branch is counted at the wrong one.
v.reset().parameter("officeId").value(this.fromApiJsonHelper.extractLongNamed("officeId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
validateGuardianForMinor(element, v);
// An account opens the day it is opened. A date in the past would make the scheme look older
// than it is - its lock-in would appear to be further along, and its maturity would fall
// earlier than the money was actually collected for. Checked only here, on creation: an
// existing account's opening date is in the past by definition, and applying this on update
// would make every account already on the books uneditable.
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull()
.validateDateAfterOrEqual(DateUtils.getBusinessLocalDate());
v.reset().parameter("dailyDepositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element))
.notNull().positiveAmount();
validateTermLength(element, v);
validateOptionalFields(element, v);
validateIdentifierFormats(element, v);
validateLien(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
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("clientId", element)) {
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("agentId", element)) {
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("subType", element)) {
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
}
if (this.fromApiJsonHelper.parameterExists("partyName", element)) {
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("address1", element)) {
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists("city", element)) {
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("pincode", element)) {
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("mobileNoSms", element)) {
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("operation", element)) {
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("adultMinor", element)) {
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("openingDate", element)) {
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
}
if (this.fromApiJsonHelper.parameterExists("dailyDepositAmount", element)) {
v.reset().parameter("dailyDepositAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element)).notNull()
.positiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("days", element)) {
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
.integerGreaterThanZero();
}
validateOptionalFields(element, v);
validateIdentifierFormats(element, v);
validateLien(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* A maturity payout takes at most one field: another savings account of the customer's to move
* the money into. Omitted means they have none here, and it is parked for transfer to their
* account at another bank.
*
* The amount is deliberately not accepted. It is whatever the account holds - a caller naming a
* figure could pay out more than the customer has, or quietly leave some behind.
*/
/**
* An early closure has to say who carries the cost - there is no sensible default, because the
* two answers take money from different people. Everything else about the terms comes from the
* scheme, so this is the only choice the caller gets to make.
*/
/**
* An early closure. One waiving the lock-in carries the reason for it as well - the one thing the
* approving manager has to go on, and what the passbook line records.
*/
public void validateForPrematureClose(final String json, final boolean withinLockin) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, withinLockin
? Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "lockinOverrideReason", "sundryGlAccountId")
: Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "sundryGlAccountId"));
final List<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);
}
if (withinLockin) {
v.reset().parameter("lockinOverrideReason").value(this.fromApiJsonHelper.extractStringNamed("lockinOverrideReason", element))
.notBlank().notExceedingLengthOf(250);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForPayout(final String json) {
if (StringUtils.isBlank(json)) {
return;
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("toSavingsAccountNo", "dateFormat", "locale", "sundryGlAccountId"));
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();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("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("closeDate").value(this.fromApiJsonHelper.extractLocalDateNamed("closeDate", element)).notNull();
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/** What the Adult/Minor field holds when the account is a minor's. */
private static final String MINOR = "Minor";
/**
* A minor's account must name the guardian who will operate it.
*
* A minor cannot contract, so somebody has to stand for them - and until they come of age that
* person is who the bank deals with. Opening one with the box left empty leaves an account nobody
* is authorised to operate and no record of who was ever entitled to: the live data already
* carries two such accounts, opened before this ran.
*
* Checked here rather than on the screen alone, so an account cannot arrive without one by any
* other route.
*/
private void validateGuardianForMinor(final JsonElement element, final DataValidatorBuilder v) {
final String adultMinor = this.fromApiJsonHelper.extractStringNamed("adultMinor", element);
if (!MINOR.equalsIgnoreCase(adultMinor)) {
return;
}
v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element)).notNull()
.longGreaterThanZero();
}
/**
* How long the scheme runs for. Either a number of months, or - where the bank quotes a run of
* days instead - a number of days; one of the two has to be there or the account has no term and
* no maturity.
*
* Where months are given, days is worked out from them rather than taken from the request.
* Collection runs every day of the term, so the two can only ever disagree by someone mistyping,
* and a days figure below the term's own length quietly understated what the account would mature
* to. Anything sent in it is ignored.
*/
private void validateTermLength(final JsonElement element, final DataValidatorBuilder v) {
final Integer months = this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element);
final Integer days = this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element);
if (months != null && months > 0) {
return;
}
// No months, so the days figure is the term and has to stand on its own.
v.reset().parameter("days").value(days).notNull().integerGreaterThanZero();
}
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
optionalString(element, v, "lastAccount", 50);
optionalString(element, v, "aadharNo", 20);
optionalString(element, v, "partyName1", 100);
optionalString(element, v, "address2", 200);
optionalString(element, v, "address3", 200);
optionalString(element, v, "gender", 20);
optionalString(element, v, "panCardNo", 20);
optionalString(element, v, "telephone", 20);
optionalString(element, v, "occupation", 40);
optionalString(element, v, "risk", 20);
optionalString(element, v, "ckycId", 40);
optionalString(element, v, "minAccountOpt", 40);
optionalString(element, v, "relation", 40);
optionalString(element, v, "ledgerNo", 40);
optionalString(element, v, "withdrawType", 20);
optionalString(element, v, "spDescription", 500);
optionalString(element, v, "freezeCode", 20);
optionalString(element, v, "advCode", 40);
optionalString(element, v, "creditToBranch", 100);
optionalString(element, v, "creditToAccountType", 100);
optionalString(element, v, "creditToAccount", 50);
optionalString(element, v, "nomineeSplitMode", 20);
optionalString(element, v, "savingsAccountNo", 20);
optionalString(element, v, "lienReason", 200);
if (this.fromApiJsonHelper.parameterExists("guardianClientId", element)) {
v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element))
.ignoreIfNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("month", element)) {
v.reset().parameter("month").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("depositAmount", element)) {
v.reset().parameter("depositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("depositAmount", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("paymentAmount", element)) {
v.reset().parameter("paymentAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("paymentAmount", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("openingBalance", element)) {
v.reset().parameter("openingBalance")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("openingBalance", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void optionalString(final JsonElement element, final DataValidatorBuilder v, final String paramName, final int maxLength) {
if (this.fromApiJsonHelper.parameterExists(paramName, element)) {
v.reset().parameter(paramName).value(this.fromApiJsonHelper.extractStringNamed(paramName, element)).ignoreIfNull()
.notExceedingLengthOf(maxLength);
}
}
/**
* Checks a value against the shape its kind has to take, and says what that shape is.
*
* The message names the format rather than only rejecting the entry: "must be 10 digits" tells
* the clerk what to type next, where "invalid" leaves them guessing at which of the ten
* characters the machine dislikes.
*
* Blank is left alone - whether the field is required at all is a separate question, answered
* by notBlank where it applies.
*/
private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape,
final String describedAs) {
if (StringUtils.isBlank(value)) {
return;
}
// matchesRegularExpression with a message puts that message where the clerk actually reads
// it. failWithCode leaves it in the args and shows "Failed data validation due to:
// invalid.format" instead, which names the fault without naming the fix.
v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs);
}
/**
* The identifiers, each against its own shape.
*
* Applied on create and update alike: a number corrected into a wrong one is no better than a
* wrong one typed first.
*/
private void validateIdentifierFormats(final JsonElement element, final DataValidatorBuilder v) {
mustLookLike(v, "mobileNoSms", this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element), MOBILE_PATTERN,
"Mobile number must be 10 digits starting 6-9");
mustLookLike(v, "telephone", this.fromApiJsonHelper.extractStringNamed("telephone", element), TELEPHONE_PATTERN,
"Telephone number may contain only digits, spaces and + - ( )");
mustLookLike(v, "pincode", this.fromApiJsonHelper.extractStringNamed("pincode", element), PINCODE_PATTERN,
"Pincode must be 6 digits and cannot start with 0");
mustLookLike(v, "aadharNo", this.fromApiJsonHelper.extractStringNamed("aadharNo", element), AADHAAR_PATTERN,
"Aadhaar must be 12 digits, or the masked form XXXXXXXX1234");
mustLookLike(v, "panCardNo", this.fromApiJsonHelper.extractStringNamed("panCardNo", element), PAN_PATTERN,
"PAN must match format ABCDE1234F");
mustLookLike(v, "ckycId", this.fromApiJsonHelper.extractStringNamed("ckycId", element), CKYC_PATTERN,
"CKYC ID must be 14 digits");
}
/**
* The nominees themselves, and - under a Percentage split - that they add up.
*
* A nomination that does not total the whole deposit is worse than no nomination at all: it
* reads as a decision the customer made, and leaves the remainder with nobody named to receive
* it. So the shares are checked as a set here rather than field by field, and the request is
* refused rather than saved as a partial split somebody would have to chase later.
*
* Under a Sequence split there is nothing to add up - the order the nominees are given in is
* the order the bank pays in, and no share is stated.
*/
private void validateNominees(final JsonObject topLevelJson, final List<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().parameter(NOMINEES).parameterAtIndexArray("nominationDate", i)
.value(extractString(nomineeJson, "nominationDate")).notBlank();
v.reset().parameter(NOMINEES).parameterAtIndexArray("name", i).value(extractString(nomineeJson, "name")).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter(NOMINEES).parameterAtIndexArray("relation", i).value(extractString(nomineeJson, "relation")).notBlank()
.notExceedingLengthOf(40);
v.reset().parameter(NOMINEES).parameterAtIndexArray("gender", i).value(extractString(nomineeJson, "gender")).notBlank()
.notExceedingLengthOf(20);
// A nominee the bank cannot reach, or whose age it does not know, is a name and nothing
// more - and the age is what decides whether a guardian is needed at all.
v.reset().parameter(NOMINEES).parameterAtIndexArray("mobileNo", i).value(extractString(nomineeJson, "mobileNo")).notBlank()
.notExceedingLengthOf(20);
// Held to the same shape as the customer's own. A nominee reached by SMS is the point of
// taking the number at all.
mustLookLike(v, NOMINEES, extractString(nomineeJson, "mobileNo"), MOBILE_PATTERN,
"Nominee mobile number must be 10 digits starting 6-9");
v.reset().parameter(NOMINEES).parameterAtIndexArray("birthDate", i).value(extractString(nomineeJson, "birthDate"))
.notBlank();
final LocalDate birthDate = this.fromApiJsonHelper.extractLocalDateNamed("birthDate", nomineeJson, dateFormat, locale);
final LocalDate nominationDate = this.fromApiJsonHelper.extractLocalDateNamed("nominationDate", nomineeJson, dateFormat,
locale);
// Nobody is born after the day they are nominated. Caught here rather than left to read
// oddly later, because a date typed a year out is the kind of slip that only shows up
// when the guardian rule it drives turns out to have been decided on nonsense.
if (birthDate != null && nominationDate != null && birthDate.isAfter(nominationDate)) {
v.reset().parameter(NOMINEES).parameterAtIndexArray("birthDate", i).value(birthDate)
.failWithCode("cannot.be.after.the.nomination.date");
}
// A minor cannot receive the proceeds themselves, so somebody has to be named who can.
// Driven by the date of birth rather than by a separate answer, which is the whole point
// of no longer asking for one.
if (PartyNominee.isUnderAgeOn(birthDate, nominationDate)) {
v.reset().parameter(NOMINEES).parameterAtIndexArray("guardianName", i)
.value(extractString(nomineeJson, "guardianName")).notBlank().notExceedingLengthOf(100);
}
if (byPercentage) {
final BigDecimal share = extractBigDecimal(nomineeJson, "sharePercent");
v.reset().parameter(NOMINEES).parameterAtIndexArray("sharePercent", i).value(share).notNull().positiveAmount();
if (share != null) {
totalShare = totalShare.add(share);
}
} else {
final Integer sequence = extractInteger(nomineeJson, "sequenceNo");
v.reset().parameter(NOMINEES).parameterAtIndexArray("sequenceNo", i).value(sequence).notNull()
.inMinMaxRange(1, nomineesArray.size());
if (sequence == null) {
everySequenceStated = false;
} else if (!sequencesSeen.add(sequence)) {
v.reset().parameter(NOMINEES).failWithCode("order.used.more.than.once", sequence);
}
}
}
if (byPercentage && nomineesArray.size() > 0 && totalShare.compareTo(HUNDRED_PERCENT) != 0) {
v.reset().parameter(NOMINEES).value(totalShare).failWithCode("shares.must.total.one.hundred", totalShare);
}
// Every place in the order filled exactly once. Checked as a set rather than per nominee
// because a gap is only visible across the whole list - 1 and 3 are each valid on their own,
// and together they leave nobody second.
if (!byPercentage && everySequenceStated && sequencesSeen.size() != nomineesArray.size()) {
v.reset().parameter(NOMINEES).failWithCode("order.must.run.from.one.without.gaps", nomineesArray.size());
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private Integer extractInteger(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsInt() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<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);
}
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import java.util.Collection;
import java.util.Map;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
public interface PartyReadPlatformService {
/**
* The customer's accounts a DDS maturity may be paid into.
*
* The same rules the payout enforces, applied here so a screen cannot offer a choice the server
* will refuse: an active savings account of theirs, not a fixed or recurring deposit, and not
* another of their DDS scheme accounts. Working it out here rather than in each screen keeps the
* list and the refusal in step - they drifted apart once already, and the accounts on offer
* included a scheme account belonging to a party that had already closed.
*/
Collection<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
/**
* How a closed account was closed - early or at maturity, by whom, what the balance came to and
* where it went - gathered from its transactions, the ledger and the command that closed it.
*/
Map<String, Object> retrieveClosure(Long partyId);
/**
* @param agentId
* when given, only the parties this agent collects for - the daily transaction screen
* works one agent at a time, and a bank's whole book is far more than it needs.
*/
Collection<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();
}
@@ -0,0 +1,508 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.data.PartyNomineeData;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final PartyMapper PARTY_MAPPER = new PartyMapper();
private static final PartyNomineeMapper NOMINEE_MAPPER = new PartyNomineeMapper();
private static final class PartyMapper implements RowMapper<PartyData> {
public String schema() {
return " p.id as id, p.client_id as clientId, c.display_name as clientName, p.agent_id as agentId, "
+ " a.agent_code as agentCode, a.agent_name as agentName, p.office_id as officeId, o.name as officeName, "
+ " p.savings_account_id as savingsAccountId, "
+ " sa.account_no as savingsAccountNo, sp.name as savingsProductName, "
+ " sa.account_balance_derived as savingsAccountBalance, "
+ " coalesce(sa.on_hold_funds_derived, 0) + coalesce(sa.total_savings_amount_on_hold, 0) as amountHeldUnderLien, "
// How many instalments should have been paid by today, and how much actually has
// been. The first instalment falls on the opening day itself - the same day the
// maturity figure starts counting from, so the two agree - and the count stops at
// the term's own length so a matured account does not keep falling behind.
//
// Only PARTY_TRAN counts. Interest is the bank's to pay and a transfer in from
// another party is not this customer keeping to their schedule; counting either
// would show an account as up to date on days nobody collected.
+ " least(greatest((current_date - p.opening_date) + 1, 0), coalesce(p.days, 0)) as instalmentsDue, "
+ " coalesce((select sum(ct.amount) from m_dds_party_transaction ct "
+ " where ct.party_id = p.id and ct.transaction_type = 'PARTY_TRAN' "
+ " and ct.direction = 'CREDIT'), 0) as collectedToDate, "
+ " case sa.status_enum when 100 then 'Submitted' when 200 then 'Approved' when 300 then 'Active' "
+ " when 600 then 'Closed' when 700 then 'Rejected' end as savingsAccountStatus, "
+ " p.sub_type as subType, t.account_type_description as subTypeDescription, "
+ " p.party_code as partyCode, p.account_no as accountNo, p.last_account as lastAccount, p.nominal as nominal, "
+ " p.aadhar_no as aadharNo, p.party_name as partyName, p.party_name1 as partyName1, p.address1 as address1, "
+ " p.address2 as address2, p.address3 as address3, p.city as city, p.pincode as pincode, p.gender as gender, "
+ " p.pan_card_no as panCardNo, p.telephone as telephone, p.mobile_no_sms as mobileNoSms, p.operation as operation, "
+ " p.birth_date as birthDate, p.occupation as occupation, p.risk as risk, p.ckyc_id as ckycId, "
+ " p.adult_minor as adultMinor, p.minor_date as minorDate, p.guardian_client_id as guardianClientId, "
// When a minor account's holder comes of age, and whether that day has passed.
// Worked out from the birth date rather than read off minor_date, which is filled
// in by hand and empty on every record so far. Eighteen is majority in India.
//
// The guardian's authority to operate the account ends on that day by law, so the
// branch has to take fresh KYC, a signature and a new mandate from the customer
// themselves. Nothing here acts on it - it only makes it visible, because until
// now the day passed with nobody being told.
+ " case when lower(p.adult_minor) = 'minor' and p.birth_date is not null "
+ " then p.birth_date + interval '18 years' end as majorOn, "
+ " case when lower(p.adult_minor) = 'minor' and p.birth_date is not null "
+ " then p.birth_date + interval '18 years' <= current_date else false end as minorTurnedMajor, "
+ " p.min_account_opt as minAccountOpt, p.relation as relation, p.opening_date as openingDate, "
+ " p.months as month, p.maturity_date as maturityDate, p.reopen_date as reopenDate, "
+ " p.deposit_amount as depositAmount, p.daily_deposit_amount as dailyDepositAmount, p.days as days, "
+ " p.ledger_no as ledgerNo, p.maturity_amount as maturityAmount, p.interest_rate as interestRate, "
+ " p.withdraw_type as withdrawType, p.od1_flag as od1Flag, p.od2_flag as od2Flag, p.lien as lien, "
+ " p.lien_reason as lienReason, "
+ " p.pay_date as payDate, p.payment_amount as paymentAmount, p.current_balance as currentBalance, "
+ " p.opening_balance as openingBalance, p.is_sms_active as isSmsActive, p.is_email_active as isEmailActive, "
+ " p.interest_calc as interestCalc, p.interest_date as interestDate, "
+ " p.od_interest_calc as odInterestCalc, p.od_interest_date as odInterestDate, p.sp_description as spDescription, "
+ " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, "
+ " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, "
+ " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, "
+ " p.status as status, p.close_date as closeDate "
// Left join: sub_type is a plain code column rather than a foreign key, so a party
// whose account type was renamed away or predates the master still lists, just
// without a description.
+ " from m_dds_party p join m_client c on c.id = p.client_id join m_dds_agent a on a.id = p.agent_id "
// Left join: an account written before it carried a branch of its own has none.
+ " left join m_office o on o.id = p.office_id "
+ " left join m_dds_account_type t on t.account_type_code = p.sub_type"
// The customer's real deposit account. Left join: parties opened before DDS began
// opening real accounts have none, and they still have to list.
+ " left join m_savings_account sa on sa.id = p.savings_account_id"
+ " left join m_savings_product sp on sp.id = sa.product_id";
}
@Override
public PartyData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final BigDecimal daily = rs.getBigDecimal("dailyDepositAmount");
final int instalmentsDue = rs.getInt("instalmentsDue");
final BigDecimal collected = rs.getBigDecimal("collectedToDate");
final DdsSchedule schedule = DdsSchedule.of(daily, instalmentsDue, collected);
return PartyData.builder() //
.instalmentsDue(instalmentsDue) //
.expectedToDate(schedule.expected()) //
.collectedToDate(collected) //
.advanceAmount(schedule.advance()) //
.advanceDays(schedule.advanceDays()) //
.arrearsDays(schedule.arrearsDays()) //
.recoverableAmount(schedule.recoverable()) //
.skippedDays(schedule.skippedDays()) //
.skippedAmount(schedule.skipped()) //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
.clientName(rs.getString("clientName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.officeId(JdbcSupport.getLong(rs, "officeId")) //
.officeName(rs.getString("officeName")) //
.agentName(rs.getString("agentName")) //
.savingsAccountId(JdbcSupport.getLong(rs, "savingsAccountId")) //
.savingsAccountNo(rs.getString("savingsAccountNo")) //
.savingsProductName(rs.getString("savingsProductName")) //
.savingsAccountStatus(rs.getString("savingsAccountStatus")) //
.savingsAccountBalance(rs.getBigDecimal("savingsAccountBalance")) //
.amountHeldUnderLien(rs.getBigDecimal("amountHeldUnderLien")) //
.subType(rs.getString("subType")) //
.subTypeDescription(rs.getString("subTypeDescription")) //
.partyCode(rs.getString("partyCode")) //
.accountNo(rs.getString("accountNo")) //
.lastAccount(rs.getString("lastAccount")) //
.nominal(rs.getBoolean("nominal")) //
.aadharNo(rs.getString("aadharNo")) //
.partyName(rs.getString("partyName")) //
.partyName1(rs.getString("partyName1")) //
.address1(rs.getString("address1")) //
.address2(rs.getString("address2")) //
.address3(rs.getString("address3")) //
.city(rs.getString("city")) //
.pincode(rs.getString("pincode")) //
.gender(rs.getString("gender")) //
.panCardNo(rs.getString("panCardNo")) //
.telephone(rs.getString("telephone")) //
.mobileNoSms(rs.getString("mobileNoSms")) //
.operation(rs.getString("operation")) //
.birthDate(JdbcSupport.getLocalDate(rs, "birthDate")) //
.occupation(rs.getString("occupation")) //
.risk(rs.getString("risk")) //
.ckycId(rs.getString("ckycId")) //
.adultMinor(rs.getString("adultMinor")) //
.minorDate(JdbcSupport.getLocalDate(rs, "minorDate")) //
.majorOn(JdbcSupport.getLocalDate(rs, "majorOn")) //
.minorTurnedMajor(rs.getBoolean("minorTurnedMajor")) //
.guardianClientId(JdbcSupport.getLong(rs, "guardianClientId")) //
.minAccountOpt(rs.getString("minAccountOpt")) //
.relation(rs.getString("relation")) //
.openingDate(JdbcSupport.getLocalDate(rs, "openingDate")) //
.month(JdbcSupport.getInteger(rs, "month")) //
.maturityDate(JdbcSupport.getLocalDate(rs, "maturityDate")) //
.reopenDate(JdbcSupport.getLocalDate(rs, "reopenDate")) //
.depositAmount(rs.getBigDecimal("depositAmount")) //
.dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) //
.days(JdbcSupport.getInteger(rs, "days")) //
.ledgerNo(rs.getString("ledgerNo")) //
.maturityAmount(rs.getBigDecimal("maturityAmount")) //
.interestRate(rs.getBigDecimal("interestRate")) //
.withdrawType(rs.getString("withdrawType")) //
.od1Flag(rs.getBoolean("od1Flag")) //
.od2Flag(rs.getBoolean("od2Flag")) //
.lien(rs.getBoolean("lien")) //
.lienReason(rs.getString("lienReason")) //
.payDate(JdbcSupport.getLocalDate(rs, "payDate")) //
.paymentAmount(rs.getBigDecimal("paymentAmount")) //
.currentBalance(rs.getBigDecimal("currentBalance")) //
.openingBalance(rs.getBigDecimal("openingBalance")) //
.isSmsActive(rs.getBoolean("isSmsActive")) //
.isEmailActive(rs.getBoolean("isEmailActive")) //
.interestCalc(rs.getBigDecimal("interestCalc")) //
.odInterestCalc(rs.getBigDecimal("odInterestCalc")) //
.odInterestDate(JdbcSupport.getLocalDate(rs, "odInterestDate")) //
.interestDate(JdbcSupport.getLocalDate(rs, "interestDate")) //
.spDescription(rs.getString("spDescription")) //
.freezeCode(rs.getString("freezeCode")) //
.advCode(rs.getString("advCode")) //
.creditToBranch(rs.getString("creditToBranch")) //
.creditToAccountType(rs.getString("creditToAccountType")) //
.creditToAccount(rs.getString("creditToAccount")) //
.nomineeApplicable(rs.getBoolean("nomineeApplicable")) //
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
.status(rs.getString("status")) //
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
.nominees(new ArrayList<>()) //
.build();
}
}
private static final class PartyNomineeMapper implements RowMapper<Map.Entry<Long, PartyNomineeData>> {
public String schema() {
return " n.party_id as partyId, n.id as id, n.nomination_date as nominationDate, n.name as name, "
+ " n.mobile_no as mobileNo, n.birth_date as birthDate, n.minor as minor, n.relation as relation, "
+ " n.gender as gender, n.guardian_name as guardianName, n.share_percent as sharePercent, "
+ " n.sequence_no as sequenceNo from m_dds_party_nominee n";
}
@Override
public Map.Entry<Long, PartyNomineeData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long partyId = rs.getLong("partyId");
final PartyNomineeData data = PartyNomineeData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "nominationDate"),
rs.getString("name"), rs.getString("mobileNo"), JdbcSupport.getLocalDate(rs, "birthDate"), rs.getBoolean("minor"),
rs.getString("relation"), rs.getString("gender"), rs.getString("guardianName"),
// Read as objects, not primitives: whichever split an account was nominated
// under, the other's column is null, and getInt would turn that into a zero
// that reads as a real answer.
rs.getBigDecimal("sharePercent"), JdbcSupport.getInteger(rs, "sequenceNo"));
return Map.entry(partyId, data);
}
}
@Override
public Collection<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() {
// Matured as of the bank's business date - the date the payout itself checks - not the
// server's clock, so the list never offers an account the payout would call not yet matured.
final String sql = "select " + PARTY_MAPPER.schema()
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= ?"
+ " order by p.maturity_date, p.party_code";
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR
}
@Override
public PartyData retrieveOne(final Long partyId) {
try {
final String sql = "select " + PARTY_MAPPER.schema() + " where p.id = ?";
final PartyData party = this.jdbcTemplate.queryForObject(sql, PARTY_MAPPER, partyId); // NOSONAR
return attachNominees(List.of(party)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new PartyNotFoundException(partyId, e);
}
}
private Collection<PartyData> attachNominees(final List<PartyData> parties) {
if (parties.isEmpty()) {
return parties;
}
// Ordered by the stated sequence where there is one, and otherwise by the order the names
// were entered. Under a Percentage split every sequence is null, so the first term is
// constant and the entry order stands; under a Sequence split it is the whole answer.
final String sql = "select " + NOMINEE_MAPPER.schema() + " order by coalesce(n.sequence_no, 0), n.id";
final List<Map.Entry<Long, PartyNomineeData>> rows = this.jdbcTemplate.query(sql, NOMINEE_MAPPER); // NOSONAR
final Map<Long, List<PartyNomineeData>> nomineesByPartyId = new LinkedHashMap<>();
for (final Map.Entry<Long, PartyNomineeData> row : rows) {
nomineesByPartyId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<PartyData> result = new ArrayList<>(parties.size());
for (final PartyData p : parties) {
result.add(p.toBuilder().nominees(nomineesByPartyId.getOrDefault(p.getId(), new ArrayList<>())).build());
}
return result;
}
/**
* Accounts of this customer's that a DDS maturity can actually be paid into.
*
* The conditions are the ones the payout itself applies, written once here so the choice on
* screen and the answer from the server cannot disagree: their own account, active, a savings
* account rather than a fixed or recurring deposit, and not one of their DDS scheme accounts -
* those are collected on daily and pay out at their own maturity, so a maturity sent into one
* would move money between two collection accounts instead of handing it back.
*/
// Aliases are quoted: unquoted ones are folded to lower case by the database, and the keys would
// reach the screen as accountno and productname, which read back as blank.
//
// Current accounts are offered as well: they keep their own table, and a maturity can be paid into
// one as surely as into a savings account - the payout checks the same things of it, open and the
// customer's own.
private static final String CREDITABLE_ACCOUNTS_SQL = "select * from ("
+ "select a.id as id, a.account_no as \"accountNo\", "
+ "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a "
+ "join m_savings_product p on p.id = a.product_id "
+ "where a.client_id = ? and a.status_enum = 300 and p.deposit_type_enum = 100 "
+ "and a.product_id is distinct from (select cast(value as bigint) from c_configuration "
+ "where name = 'dds-deposit-product' and enabled = true) "
+ "union all "
+ "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, "
+ "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id "
+ "where c.client_id = ? and c.status_enum = 300"
+ ") accounts order by \"accountNo\"";
@Override
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR
}
private static final String CLOSURE_PARTY_SQL = "select p.party_code as \"partyCode\", p.status, p.close_date as \"closeDate\", "
+ "a.agent_code as \"agentCode\" from m_dds_party p left join m_dds_agent a on a.id = p.agent_id where p.id = ?";
private static final String CLOSURE_PAYOUT_SQL = "select transaction_type as \"type\", amount, transaction_date as \"date\", "
+ "account_no as \"accountNo\", description, voucher_no as \"voucherNo\" from m_dds_party_transaction where party_id = ? "
+ "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1";
private static final String CLOSURE_ADJUSTMENTS_SQL = "select "
+ "coalesce(sum(case when transaction_type = 'PREMATURE_INT_RECOVERY' then amount end), 0) as \"recovered\", "
+ "coalesce(sum(case when transaction_type = 'PREMATURE_AGENT_TOP_UP' then amount end), 0) as \"toppedUp\" "
+ "from m_dds_party_transaction where party_id = ?";
/**
* The commands that closed the account. Party ids can come round again after test data is cleared,
* so only commands made since this party was opened are read - an older one belongs to someone else.
*/
private static final String CLOSURE_COMMANDS_SQL = "select c.action_name as \"action\", c.command_as_json as \"json\", "
+ "c.result, mk.username as \"maker\", ck.username as \"checker\" "
+ "from m_portfolio_command_source c left join m_appuser mk on mk.id = c.maker_id "
+ "left join m_appuser ck on ck.id = c.checker_id where c.entity_name = 'PARTY' and c.resource_id = ? and c.status = 1 "
+ "and c.action_name in ('PAYOUT', 'CLOSE', 'CLOSEEARLY', 'PREMATURECLOSEOVERRIDE') "
+ "and c.made_on_date_utc >= (select created_date from m_dds_party where id = ?) order by c.id";
/** Both sides of the payout on the ledger - where it left and where it landed. */
private static final String CLOSURE_LEDGER_SQL = "select j.transaction_id as \"transactionId\", g.gl_code as \"glCode\", "
+ "g.name as \"glName\", case j.type_enum when 1 then 'CR' else 'DR' end as \"side\", j.amount "
+ "from acc_gl_journal_entry j join acc_gl_account g on g.id = j.account_id "
+ "where j.description = ? and j.entry_date = ? and j.reversed = false order by j.id";
private static final String CREDITED_ACCOUNT_SQL = "select s.account_no as \"accountNo\", sp.name as \"productName\", "
+ "c.display_name as \"owner\" from m_savings_account s join m_savings_product sp on sp.id = s.product_id "
+ "left join m_client c on c.id = s.client_id where s.account_no = ? "
+ "union all select ca.account_no, cp.name, c.display_name from m_current_account ca "
+ "join m_current_product cp on cp.id = ca.product_id left join m_client c on c.id = ca.client_id where ca.account_no = ?";
private static final String ACTION_CLOSE = "CLOSE";
private static final String ACTION_PAYOUT = "PAYOUT";
@Override
public Map<String, Object> retrieveClosure(final Long partyId) {
final List<Map<String, Object>> parties = this.jdbcTemplate.queryForList(CLOSURE_PARTY_SQL, partyId); // NOSONAR
if (parties.isEmpty()) {
throw new PartyNotFoundException(partyId);
}
final Map<String, Object> party = parties.get(0);
final Map<String, Object> closure = new LinkedHashMap<>();
final boolean closed = "CLOSED".equals(party.get("status"));
closure.put("closed", closed);
if (!closed) {
return closure;
}
final String partyCode = (String) party.get("partyCode");
closure.put("closeDate", isoDate(party.get("closeDate")));
closure.put("agentCode", party.get("agentCode"));
final List<Map<String, Object>> payouts = this.jdbcTemplate.queryForList(CLOSURE_PAYOUT_SQL, partyId); // NOSONAR
final Map<String, Object> payout = payouts.isEmpty() ? null : payouts.get(0);
final Map<String, Object> adjustments = this.jdbcTemplate.queryForMap(CLOSURE_ADJUSTMENTS_SQL, partyId); // NOSONAR
final BigDecimal recovered = (BigDecimal) adjustments.get("recovered");
final BigDecimal toppedUp = (BigDecimal) adjustments.get("toppedUp");
final BigDecimal paidOut = payout == null ? BigDecimal.ZERO : (BigDecimal) payout.get("amount");
// The command that did the closing, and a plain Close that followed a payout made the old way.
Map<String, Object> closingCommand = null;
Map<String, Object> laterClose = null;
for (final Map<String, Object> command : this.jdbcTemplate.queryForList(CLOSURE_COMMANDS_SQL, partyId, partyId)) { // NOSONAR
if (ACTION_CLOSE.equals(command.get("action")) && closingCommand != null) {
laterClose = command;
} else {
closingCommand = command;
laterClose = null;
}
}
final String closingAction = closingCommand == null ? null : (String) closingCommand.get("action");
final boolean premature = payout != null ? "PREMATURE_PAYOUT".equals(payout.get("type"))
: closingAction != null && !ACTION_CLOSE.equals(closingAction) && !ACTION_PAYOUT.equals(closingAction);
final String closureType;
if (premature) {
closureType = "PREMATURE";
} else if (payout == null) {
closureType = "CLOSED_NIL_BALANCE";
} else if (!ACTION_PAYOUT.equals(closingAction)) {
// Paid out with no clerk's command behind it: the nightly Maturity Auto Close.
closureType = "MATURITY_AUTO_CLOSE";
} else {
closureType = laterClose != null ? "MATURITY_PAYOUT_THEN_CLOSE" : "MATURITY_PAYOUT_AND_CLOSE";
}
closure.put("closureType", closureType);
closure.put("withinLockin", "PREMATURECLOSEOVERRIDE".equals(closingAction));
final Map<String, Object> actedBy = laterClose != null ? laterClose : closingCommand;
closure.put("closedBy", "MATURITY_AUTO_CLOSE".equals(closureType) ? "System" : actedBy == null ? null : actedBy.get("maker"));
closure.put("approvedBy", "MATURITY_AUTO_CLOSE".equals(closureType) || actedBy == null ? null : actedBy.get("checker"));
if (laterClose != null) {
closure.put("paidOutBy", closingCommand.get("maker"));
}
final JsonObject request = jsonOf(closingCommand == null ? null : closingCommand.get("json"));
final JsonObject result = jsonOf(closingCommand == null ? null : closingCommand.get("result"));
final JsonObject changes = result != null && result.has("changes") && result.get("changes").isJsonObject()
? result.getAsJsonObject("changes")
: null;
closure.put("lockinOverrideReason", stringOf(request, "lockinOverrideReason"));
closure.put("shortfallBorneBy", premature ? stringOf(request, "shortfallBorneBy") : null);
closure.put("agentCommissionPenalty",
changes != null && changes.has("agentCommissionPenalty") && !changes.get("agentCommissionPenalty").isJsonNull()
? changes.get("agentCommissionPenalty").getAsBigDecimal()
: BigDecimal.ZERO);
// What the account held when closing began: what went out, plus what was taken back, less what the agent put in.
closure.put("balanceAtClose", paidOut.add(recovered).subtract(toppedUp));
closure.put("interestRecovered", recovered);
closure.put("interestToppedUpByAgent", toppedUp);
closure.put("paidOut", paidOut);
if (payout != null) {
final String accountNo = (String) payout.get("accountNo");
closure.put("payoutDate", isoDate(payout.get("date")));
closure.put("passbookNote", payout.get("description"));
closure.put("voucherNo", payout.get("voucherNo"));
final String ledgerDescription = (premature ? "DDS premature closure payout for " : "DDS maturity payout for ") + partyCode;
final List<Map<String, Object>> ledger = this.jdbcTemplate.queryForList(CLOSURE_LEDGER_SQL, ledgerDescription, // NOSONAR
payout.get("date"));
// Only the last posting, should the same account ever have been paid out twice.
final Object lastTransaction = ledger.isEmpty() ? null : ledger.get(ledger.size() - 1).get("transactionId");
final List<Map<String, Object>> legs = ledger.stream().filter(row -> row.get("transactionId").equals(lastTransaction)).toList();
closure.put("ledgerTransactionId", lastTransaction);
closure.put("ledgerEntries", legs);
if (accountNo != null && !accountNo.isBlank()) {
closure.put("destination", "OWN_ACCOUNT");
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(CREDITED_ACCOUNT_SQL, accountNo, accountNo); // NOSONAR
closure.put("creditedAccount", accounts.isEmpty() ? Map.of("accountNo", accountNo) : accounts.get(0));
} else {
closure.put("destination", "SUNDRY");
legs.stream().filter(row -> "CR".equals(row.get("side"))).findFirst()
.ifPresent(row -> closure.put("sundryGl", Map.of("glCode", row.get("glCode"), "glName", row.get("glName"))));
}
}
return closure;
}
/** A date from the database as yyyy-MM-dd, which the screen reads - a java.sql.Date will not serialise. */
private static String isoDate(final Object value) {
if (value instanceof java.sql.Date date) {
return date.toLocalDate().toString();
}
return value == null ? null : value.toString();
}
private static JsonObject jsonOf(final Object text) {
if (!(text instanceof String json) || json.isBlank()) {
return null;
}
try {
final JsonElement element = JsonParser.parseString(json);
return element.isJsonObject() ? element.getAsJsonObject() : null;
} catch (RuntimeException e) {
return null;
}
}
private static String stringOf(final JsonObject json, final String name) {
return json != null && json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null;
}
}
@@ -0,0 +1,99 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface PartyWritePlatformService {
CommandProcessingResult createParty(JsonCommand command);
CommandProcessingResult updateParty(Long partyId, JsonCommand command);
CommandProcessingResult closeParty(Long partyId, JsonCommand command);
/**
* Opens the real deposit account of a party that predates DDS opening them, and brings its
* existing balance onto it. Run once per such party; refuses one that already has an account.
*/
CommandProcessingResult openDepositAccount(Long partyId);
/**
* Pays a matured account out. The account stays open with a nil balance.
*
* Either into another account of the customer's at this bank, or - when they have none - into the
* matured-deposits payable account, from which the branch sends it on to their account at another
* bank. Refuses an account whose term has not run out, because paying one out early is a premature
* withdrawal and that carries terms of its own.
*
* Closing is deliberately not part of this. The clerk pays, sees the balance fall to nil, and
* closes as a second act - so a payout that goes wrong leaves an open account to correct rather
* than a closed one to reopen.
*/
CommandProcessingResult payOut(Long partyId, JsonCommand command);
/**
* Pays a matured account into the account the customer already named, and closes it.
*
* This is what a Maturity Auto Close batch run applies, one account at a time. It does in one act
* what {@link #payOut} and {@link #closeParty} do in two, which that pair deliberately does not -
* and the reason it may is that the review those two rely on has already happened. A batch run is
* generated first and lists every account it would settle, with the amount and the account it
* would go into; nothing runs until a clerk has read that list and pressed Post. The check is
* made earlier than on the manual path rather than skipped.
*
* Only an account that named a destination when it was opened is settled this way - the batch
* does not look for one, and never picks. An account that named none is simply not on the list
* and is paid out by hand exactly as before.
*/
CommandProcessingResult closeAtMaturity(Long partyId);
/**
* Closes an account before its term is up, and pays out what is left.
*
* A customer who stops saving early costs the bank the interest it had promised on money it no
* longer has for as long as it expected. Somebody has to carry that, and the bank allows two
* answers - so the caller says which:
*
* <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);
/**
* Closes one account early inside its scheme's lock-in, for a reason a manager approves. The
* scheme's lock-in is not changed; only this account is let through it.
*/
CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command);
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.starter;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsPartyConfiguration {
@Bean
@ConditionalOnMissingBean(PartyReadPlatformService.class)
public PartyReadPlatformService partyReadPlatformService(JdbcTemplate jdbcTemplate) {
return new PartyReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(PartyWritePlatformService.class)
public PartyWritePlatformService partyWritePlatformService(PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer,
PartyRepository partyRepository, AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
SavingsAccountRepository savingsAccountRepository, DdsAccountTypeRepository ddsAccountTypeRepository,
SavingsApplicationProcessWritePlatformService savingsApplicationProcessWritePlatformService,
DdsLedger ddsLedger, PartyTransactionRepository partyTransactionRepository,
BatchRunWritePlatformService batchRunWritePlatformService, PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
FromJsonHelper fromApiJsonHelper, OfficeRepositoryWrapper officeRepositoryWrapper) {
return new PartyWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyRepository, agentRepository,
clientRepositoryWrapper, savingsAccountRepository, ddsAccountTypeRepository,
savingsApplicationProcessWritePlatformService, ddsLedger, partyTransactionRepository, batchRunWritePlatformService,
context, jdbcTemplate, fromApiJsonHelper, officeRepositoryWrapper);
}
}
@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/reports/{reportKey}")
@Component
@Tag(name = "DDS Reports", description = "The DDS reports suite - one shared query engine behind a generic column/row shape, filtered by date range and agent/party id range.")
@RequiredArgsConstructor
public class DdsReportsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSREPORT";
private final PlatformSecurityContext context;
private final DdsReportReadPlatformService readPlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a DDS Report", description = "reportKey is one of: AGENT_CHECK_LIST, PARTY_CHECK_LIST, PARTY_BALANCE, "
+ "AGENT_WISE_BALANCE, AGENT_WISE_MONTHLY_COLLECTION, COMMISSION_REGISTER, COLLECTION_SUMMARY_AGENT_WISE, "
+ "AGENT_PARTYWISE_DAILY_COLLECTION, DAILY_COLLECTION_REPORT, PARTY_INTEREST_REGISTER, BALANCE_SUMMARY, "
+ "TRANSACTION_REGISTER, MATURE_DATE_WISE, INTEREST_CERTIFICATE, AGENT_COMMISSION_STATEMENT, SUPPLEMENTARY_DDS, "
+ "CLOSE_PARTY_AC_REGISTER, MASTER_DATA_EXPORT (the last three always return available=false - no matching data exists "
+ "in this system yet). "
+ "fromDate/toDate are ISO (yyyy-MM-dd); agentFrom/To and partyFrom/To default to the full id range.")
public DdsReportData runReport(@PathParam("reportKey") @Parameter(description = "reportKey") final String reportKey,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate,
@DefaultValue("1") @QueryParam("agentFrom") @Parameter(description = "agentFrom") final Long agentFrom,
@DefaultValue("9223372036854775807") @QueryParam("agentTo") @Parameter(description = "agentTo") final Long agentTo,
@DefaultValue("1") @QueryParam("partyFrom") @Parameter(description = "partyFrom") final Long partyFrom,
@DefaultValue("9223372036854775807") @QueryParam("partyTo") @Parameter(description = "partyTo") final Long partyTo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final LocalDate from = parseDate("fromDate", fromDate, LocalDate.of(1900, 1, 1));
final LocalDate to = parseDate("toDate", toDate, LocalDate.of(9999, 12, 31));
// A range the wrong way round returns nothing, which reads as "no such data" rather than
// "the dates are swapped" - so it is refused instead.
if (from.isAfter(to)) {
throw badRequest("fromDate", "fromDate " + from + " is after toDate " + to + ".");
}
return readPlatformService.runReport(reportKey, from, to, agentFrom, agentTo, partyFrom, partyTo);
}
/**
* Parses a date parameter, or says which one was wrong and what it should look like.
*
* {@code LocalDate.parse} throws on anything but ISO, and an unhandled parse failure leaves the
* caller a bare 500 - which reads as a broken report rather than a malformed request. Every report
* shares these two parameters, so one bad date looked like all eighteen reports were down.
*/
private LocalDate parseDate(final String parameterName, final String value, final LocalDate absent) {
if (value == null || value.isBlank()) {
return absent;
}
try {
return LocalDate.parse(value);
} catch (final DateTimeParseException e) {
throw badRequest(parameterName, parameterName + " must be a date in yyyy-MM-dd form, not \"" + value + "\".");
}
}
private PlatformApiDataValidationException badRequest(final String parameterName, final String message) {
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.ddsreport." + parameterName.toLowerCase(Locale.ROOT)
+ ".invalid", message, parameterName);
return new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
List.of(error));
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.service;
import java.time.LocalDate;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
public interface DdsReportReadPlatformService {
DdsReportData runReport(String reportKey, LocalDate fromDate, LocalDate toDate, Long agentFrom, Long agentTo, Long partyFrom,
Long partyTo);
}
@@ -0,0 +1,534 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* One switch over every DDS report key. Each branch is a real query against data this system
* actually has (Agent/Party/PartyTransaction/CommissionSlab/BatchRun).
*
* Since SubAgent was folded into Agent (2026-08-24, Agent now does the daily collection work
* directly), the queries here dropped a join level wherever they used to go Party/Transaction ->
* SubAgent -> Agent - they now join straight to Agent.
*/
@RequiredArgsConstructor
public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformService {
private final JdbcTemplate jdbcTemplate;
/**
* The stored transaction type rendered as a person would say it. A statement that prints
* WITHDRAW_DEPOSITE reads as a database dump rather than a bank's record.
*/
private static final String TRANSACTION_TYPE_LABEL = "case t.transaction_type "
+ "when 'PARTY_TRAN' then 'Collection' " + "when 'AGENT_TRAN' then 'Settlement' "
+ "when 'WITHDRAW_DEPOSITE' then 'Transfer' " + "when 'INTEREST_POST' then 'Interest' "
+ "when 'OD_INTEREST_POST' then 'OD Interest' " + "when 'COMMISSION_POST' then 'Commission Paid' "
+ "when 'MATURITY_PAYOUT' then 'Maturity Payout' " + "when 'PREMATURE_PAYOUT' then 'Premature Payout' "
+ "when 'PREMATURE_INT_RECOVERY' then 'Interest Recovered (Premature)' "
+ "when 'PREMATURE_AGENT_TOP_UP' then 'Interest Funded by Agent' " + "else t.transaction_type end";
/**
* What an agent collected and has not yet handed in: everything they wrote down and is still
* unsettled, less what the cashier has already taken from them but no clerk has applied yet.
* The same figure the Agent Tran screen shows as still to take. It used to read a balance on the
* agent row that the cashier flow never moves, so it said nothing once cash came in at the counter.
*/
private static final String AGENT_CASH_IN_HAND = "greatest(coalesce((select sum(e.amount) from m_dds_collection_entry e "
+ "where e.agent_id = a.id and e.status = 'PENDING'), 0) - coalesce((select sum(r.amount) "
+ "from m_cashier_deposit_request r join m_code_value cv on cv.id = r.entry_type_cv_id "
+ "join acc_gl_account g on g.id = a.gl_account_id "
+ "where cv.code_value = 'DDS' and r.status = 'APPROVED' and r.account_no = g.gl_code "
+ "and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = r.id)), 0), 0)";
/** How an agent's standing is described wherever a list of agents is printed. */
private static final String AGENT_STATUS_LABEL = "case when a.close_date is not null then 'Closed' "
+ "when a.is_active then 'Active' else 'Not collecting' end";
@Override
public DdsReportData runReport(final String reportKey, final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
return switch (reportKey) {
case "AGENT_CHECK_LIST" -> agentCheckList();
case "PARTY_CHECK_LIST" -> partyCheckList(agentFrom, agentTo, partyFrom, partyTo);
case "PARTY_BALANCE" -> partyBalance(agentFrom, agentTo, partyFrom, partyTo);
case "AGENT_WISE_BALANCE" -> agentWiseBalance(agentFrom, agentTo);
case "AGENT_WISE_MONTHLY_COLLECTION" -> agentWiseCollection(fromDate, toDate, agentFrom, agentTo);
case "COMMISSION_REGISTER" -> commissionRegister(fromDate, toDate, agentFrom, agentTo);
case "COLLECTION_SUMMARY_AGENT_WISE" -> collectionSummaryAgentWise(fromDate, toDate, agentFrom, agentTo);
case "AGENT_PARTYWISE_DAILY_COLLECTION" -> agentPartywiseDailyCollection(fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
case "DAILY_COLLECTION_REPORT" -> dailyCollectionReport(fromDate, toDate, agentFrom, agentTo);
case "PARTY_INTEREST_REGISTER" -> partyInterestRegister(fromDate, toDate, partyFrom, partyTo);
case "BALANCE_SUMMARY" -> balanceSummary(agentFrom, agentTo, partyFrom, partyTo);
case "TRANSACTION_REGISTER" -> transactionRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "MATURE_DATE_WISE" -> matureDateWise(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "INTEREST_CERTIFICATE" -> interestCertificate(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "AGENT_COMMISSION_STATEMENT" -> agentCommissionStatement(fromDate, toDate);
case "SUPPLEMENTARY_DDS" -> supplementaryDds(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "CLOSE_PARTY_AC_REGISTER" -> closePartyAcRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "MASTER_DATA_EXPORT" -> masterDataExport(agentFrom, agentTo, partyFrom, partyTo);
default -> throw new UnrecognizedQueryParamException("reportKey", reportKey);
};
}
/**
* Every agent on the books - the list a supervisor checks collection rounds against.
*
* Carries how many accounts each agent is responsible for and what the bank owes them, because
* those are the two figures that decide whether an agent can be given more work or has to be
* settled with first. Status matters as much: without it a closed agent and a working one read
* identically. The cash column is named for what it is - money collected from customers that has
* not reached a branch, not a deposit balance.
*/
private DdsReportData agentCheckList() {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", "
+ AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", "
+ "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status = 'ACTIVE') as \"Accounts\", "
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ "from m_dds_agent a order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
return available("AGENT_CHECK_LIST",
List.of("Agent Code", "Agent Name", "City", "Status", "Opening Date", "Accounts", "Cash In Hand", "Commission Due"),
rows);
}
/**
* Every DDS account with the terms it was opened on - the list used to check an account against
* its passbook, so it carries the scheme, the instalment and the maturity date rather than money.
*/
private DdsReportData partyCheckList(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", p.sub_type as \"Sub Type\", "
+ "p.status as \"Status\", p.opening_date as \"Opening Date\", "
+ "p.daily_deposit_amount as \"Daily Deposit\", p.maturity_date as \"Maturity Date\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where a.id between ? and ? and p.id between ? and ? order by p.party_code";
final List<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", "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 \"Agent Name\", " + AGENT_STATUS_LABEL
+ " as \"Status\", " + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ "from m_dds_agent a where a.id between ? and ? order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo);
return available("AGENT_WISE_BALANCE", List.of("Agent Code", "Agent Name", "Status", "Cash In Hand", "Commission Due"), rows);
}
/**
* How much each agent collected over the period, and how that total was made up.
*
* The count of entries and of distinct accounts is what separates a genuine round from a single
* large deposit: the same total can mean fifty daily instalments or one lump sum, and only the
* former says the agent worked their round.
*/
private DdsReportData agentWiseCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "count(distinct t.party_id) as \"Accounts Collected From\", count(t.id) as \"Entries\", "
+ "coalesce(sum(t.amount), 0) as \"Total Collection\" from m_dds_agent a "
+ "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_type = 'PARTY_TRAN' "
+ "and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + "where a.id between ? and ? "
+ "group by a.id, a.agent_code, a.agent_name order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("AGENT_WISE_MONTHLY_COLLECTION",
List.of("Agent Code", "Agent Name", "Accounts Collected From", "Entries", "Total Collection"), rows);
}
/**
* How each agent's commission was arrived at, not merely what it came to.
*
* The register used to print the net figure and two dates, which answers "how much" but leaves an
* agent querying their payment with nowhere to look. The commission run already records the
* collection it was calculated on, the rate it found in the chart, and each deduction - so the
* register shows the whole line of reasoning, and a disputed figure can be checked without going
* back to the batch run.
*/
private DdsReportData commissionRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "r.from_date as \"Period From\", r.to_date as \"Period To\", r.base_amount as \"Collection\", "
+ "r.rate_percent as \"Rate %\", r.gross_amount as \"Gross Commission\", r.tds_amount as \"TDS\", "
+ "r.deduction_amount as \"Deductions\", r.amount as \"Net Commission\", b.posted_on_date as \"Posted On\" "
+ "from m_dds_batch_run_row r "
+ "join m_dds_batch_run b on b.id = r.batch_run_id join m_dds_agent a on a.id = r.agent_id "
+ "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? "
+ "and a.id between ? and ? order by b.posted_on_date, a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("COMMISSION_REGISTER", List.of("Agent Code", "Agent Name", "Period From", "Period To", "Collection",
"Rate %", "Gross Commission", "TDS", "Deductions", "Net Commission", "Posted On"), rows);
}
/**
* The settlement view of a period: what each agent collected, what of it reached a branch, what
* they are still holding, and what they earned on it.
*
* This used to run exactly the same query as the agent-wise collection report above, so the two
* menu entries produced identical tables. The difference is the question each answers: that one
* asks how the collection was made up, this one asks whether the money came in and what it cost.
*/
private DdsReportData collectionSummaryAgentWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
// Collected is what the agent wrote down over the period; settled is the part of it the branch
// has received and credited to customers. Both come from the agent's own entries, since a
// collection reaches the customer's passbook only once it is settled - reading passbooks alone
// counted only what was settled and called it collected.
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id "
+ "and e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ?), 0) as \"Collected\", "
+ "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id "
+ "and e.status = 'SETTLED' and e.entry_date between ? and ?), 0) as \"Settled To Branch\", "
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", "
+ "coalesce((select sum(t.amount) from m_dds_party_transaction t where t.agent_id = a.id "
+ "and t.transaction_type = 'COMMISSION_POST' and t.transaction_date between ? and ?), 0) as \"Commission Paid\" "
+ "from m_dds_agent a where a.id between ? and ? order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, fromDate, toDate, fromDate, toDate,
agentFrom, agentTo);
return available("COLLECTION_SUMMARY_AGENT_WISE",
List.of("Agent Code", "Agent Name", "Collected", "Settled To Branch", "Cash In Hand", "Commission Paid"), rows);
}
private DdsReportData agentPartywiseDailyCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
// Both sides named, not just coded - this is the sheet a collector's round is checked against,
// and a column of codes alone cannot be read against a passbook.
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "t.transaction_date as \"Transaction Date\", t.transaction_mode as \"Mode\", t.amount as \"Amount\", "
+ "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t "
+ "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("AGENT_PARTYWISE_DAILY_COLLECTION", List.of("Agent Code", "Agent Name", "Party Code", "Party Name",
"Transaction Date", "Mode", "Amount", "Balance After"), rows);
}
private DdsReportData dailyCollectionReport(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
// Collected against settled, day by day: the difference is what the agent went home holding,
// which is the figure a day-end check is actually looking for.
// Each day's collection from the agent's own entries, and how much of that day's collection
// the branch has since received and credited - the rest is still with the agent.
final String sql = "select e.entry_date as \"Transaction Date\", a.agent_code as \"Agent Code\", "
+ "a.agent_name as \"Agent Name\", count(*) as \"Entries\", coalesce(sum(e.amount), 0) as \"Collected\", "
+ "coalesce(sum(e.amount) filter (where e.status = 'SETTLED'), 0) as \"Settled To Branch\" "
+ "from m_dds_collection_entry e join m_dds_agent a on a.id = e.agent_id "
+ "where e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ? "
+ "and a.id between ? and ? group by e.entry_date, a.id, a.agent_code, a.agent_name "
+ "order by e.entry_date, a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("DAILY_COLLECTION_REPORT",
List.of("Transaction Date", "Agent Code", "Agent Name", "Entries", "Collected", "Settled To Branch"), rows);
}
private DdsReportData partyInterestRegister(final LocalDate fromDate, final LocalDate toDate, final Long partyFrom,
final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", t.transaction_date as \"Posted On\", " + TRANSACTION_TYPE_LABEL
+ " as \"Interest Type\", case when t.direction = 'DEBIT' then -t.amount else t.amount end as \"Interest\", "
+ "t.balance_after_transaction as \"Balance After\" "
+ "from m_dds_party_transaction t join m_dds_party p on p.id = t.party_id "
+ "join m_dds_agent a on a.id = t.agent_id "
// Interest given back on an early closure is part of the interest story, shown negative.
+ "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', 'PREMATURE_AGENT_TOP_UP', "
+ "'PREMATURE_INT_RECOVERY') and t.transaction_date between ? and ? "
+ "and p.id between ? and ? order by t.transaction_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, partyFrom, partyTo);
return available("PARTY_INTEREST_REGISTER",
List.of("Party Code", "Party Name", "Agent Code", "Posted On", "Interest Type", "Interest", "Balance After"), rows);
}
/**
* The whole scheme on one line: how many are on the books, how many are working, and where the
* money is sitting.
*
* The counts are split active from total because the two answer different questions - how big the
* scheme is, and how much of it is live. And the money is split three ways because it genuinely
* sits in three places: in customers' balances, in interest worked out but not yet credited, and
* in cash the agents have collected but not handed over. A single "total balance" hides the last
* two, and it is the third that a branch most wants to see.
*/
private DdsReportData balanceSummary(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String agentSql = "select count(*) as \"Total Agents\", "
+ "count(*) filter (where is_active and close_date is null) as \"Active Agents\", "
+ "coalesce(sum(" + AGENT_CASH_IN_HAND + "), 0) as \"Cash With Agents\", "
+ "coalesce(sum(commission), 0) as \"Commission Due To Agents\" from m_dds_agent a where a.id between ? and ?";
final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo);
final String partySql = "select count(*) as \"Total Accounts\", "
+ "count(*) filter (where status = 'ACTIVE') as \"Active Accounts\", "
+ "coalesce(sum(current_balance), 0) as \"Deposits Held\", "
+ "coalesce(sum(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" "
+ "from m_dds_party where agent_id between ? and ? and id between ? and ?";
final Map<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) {
// Left join, and a party filter that lets nulls through: an agent's settlement and a
// commission payment carry no party, so an inner join silently dropped them - a register
// missing whole classes of entry is worse than no register, because it looks complete.
//
// Debit and credit in their own columns rather than an amount beside a direction word: that
// is how a ledger is read, and it lets each side be totalled.
final String sql = "select t.transaction_date as \"Date\", " + TRANSACTION_TYPE_LABEL + " as \"Type\", "
+ "p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", t.transaction_mode as \"Mode\", "
+ "case when t.direction = 'DEBIT' then t.amount end as \"Debit\", "
+ "case when t.direction = 'CREDIT' then t.amount end as \"Credit\", "
+ "t.balance_after_transaction as \"Balance After\", t.description as \"Description\" "
+ "from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
+ "join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_date between ? and ? and a.id between ? and ? "
+ "and (t.party_id is null or t.party_id between ? and ?) " + "order by t.transaction_date, t.id";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("TRANSACTION_REGISTER", List.of("Date", "Type", "Party Code", "Party Name", "Agent Code", "Mode", "Debit",
"Credit", "Balance After", "Description"), rows);
}
private DdsReportData matureDateWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo,
final Long partyFrom, final Long partyTo) {
// What the account will be worth beside what it is worth now: this is the list a branch works
// from to arrange payouts, so it has to say who, when and how much - and the shortfall, since
// an account short of its quoted maturity amount is one whose instalments were missed.
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", p.opening_date as \"Opening Date\", "
+ "p.maturity_date as \"Maturity Date\", p.current_balance as \"Current Balance\", "
+ "p.maturity_amount as \"Maturity Amount\", "
+ "(p.maturity_amount - p.current_balance) as \"Shortfall\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
// Open accounts only: this is the list payouts are arranged from, and a closed account
// has nothing left to pay.
+ "where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date between ? and ? "
+ "and p.agent_id between ? and ? "
+ "and p.id between ? and ? order by p.maturity_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("MATURE_DATE_WISE", List.of("Party Code", "Party Name", "Agent Code", "Opening Date", "Maturity Date",
"Current Balance", "Maturity Amount", "Shortfall"), rows);
}
/**
* The interest a customer earned over a period - a document that leaves the bank and goes to the
* customer, who may file it with a tax return.
*
* So it carries what identifies the holder to someone outside the bank: their address and, where
* they have given it, their PAN. It also states the rate, since a certificate that gives an
* amount without the rate it was earned at cannot be checked by the person receiving it.
*/
private DdsReportData interestCertificate(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "p.address1 as \"Address\", p.city as \"City\", p.pan_card_no as \"PAN\", "
+ "p.opening_date as \"Opening Date\", p.interest_rate as \"Interest Rate %\", "
// Net of interest given back on an early closure, and including what the agent funded
// - the interest the customer actually kept, which is what a tax return needs.
+ "p.current_balance as \"Current Balance\", coalesce((select sum(case when t.direction = 'DEBIT' "
+ "then -t.amount else t.amount end) from m_dds_party_transaction t "
+ "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', "
+ "'PREMATURE_AGENT_TOP_UP', 'PREMATURE_INT_RECOVERY') "
+ "and t.transaction_date between ? and ?), 0) as \"Interest Earned\" from m_dds_party p "
+ "where p.agent_id between ? and ? and p.id between ? and ? order by p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("INTEREST_CERTIFICATE", List.of("Party Code", "Party Name", "Address", "City", "PAN", "Opening Date",
"Interest Rate %", "Current Balance", "Interest Earned"), rows);
}
/**
* Per-agent settlement statement for a date range: collection volume, commission actually
* posted in that window (via the same BatchRunRow join as COMMISSION_REGISTER), and the
* deduction figures tracked directly on the agent.
*
* "Commission" here is already net of the deductions, because the commission run subtracts them
* when it works the figure out - so the deduction columns show what was taken off, and "Total"
* repeats the net rather than subtracting twice. "PC Diff" has no corresponding data anywhere in
* this schema, so it reports as 0 rather than a guessed figure - matching the legacy sample where
* most of these columns were 0.00 too. No agent id range filter is applied here: the legacy
* screenshot for this report only ever showed a date range, no agent-range inputs.
*/
private DdsReportData agentCommissionStatement(final LocalDate fromDate, final LocalDate toDate) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "a.agent_saving_account_no as \"A/c No\", coalesce(collection.total, 0) as \"Total Collection\", "
+ "coalesce(commission.gross, 0) as \"Commission\", coalesce(commission.tds, 0) as \"TDS\", "
+ "a.deduct_commission as \"Deduct Commission\", "
+ "a.agent_deposit as \"Agent Deposit\", a.festival_advance as \"Festival Advance\", "
+ "a.other_deduction as \"Other Deduction\", coalesce(commission.total, 0) as \"Total\", "
+ "0 as \"PC Diff\" " + "from m_dds_agent a "
+ "left join (select t.agent_id as agent_id, sum(t.amount) as total from m_dds_party_transaction t "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "group by t.agent_id) collection on collection.agent_id = a.id "
+ "left join (select r.agent_id as agent_id, sum(r.gross_amount) as gross, sum(r.tds_amount) as tds, "
+ "sum(r.amount) as total from m_dds_batch_run_row r " + "join m_dds_batch_run b on b.id = r.batch_run_id "
+ "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? "
+ "group by r.agent_id) commission on commission.agent_id = a.id " + "order by a.agent_code";
final List<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", "TDS", "Deduct Commission",
"Agent Deposit", "Festival Advance", "Other Deduction", "Total", "PC Diff"),
rows);
}
/**
* The legacy Supplementary-DDS (As On) report: every regular collection (PARTY_TRAN, CREDIT)
* posted in the given date range - clarified with the business as "the regular transactions
* going on" (2026-08-24), same shape as the other collection-listing reports.
*/
private DdsReportData supplementaryDds(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo,
final Long partyFrom, final Long partyTo) {
// The agent is named as well as coded, the same way the party is - a row that identifies one
// side by name and the other by a code alone reads as an oversight to whoever checks it.
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "t.transaction_date as \"Transaction Date\", t.transaction_mode as \"Mode\", t.amount as \"Amount\", "
+ "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t "
+ "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("SUPPLEMENTARY_DDS", List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Transaction Date",
"Mode", "Amount", "Balance After"), rows);
}
/**
* The legacy Close Party Ac Register report: Parties closed (via the Close Party command) in the
* given date range, with their close date and final (zero) balance.
*/
private DdsReportData closePartyAcRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", "
+ "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where p.status = 'CLOSED' and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
+ "order by p.close_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("CLOSE_PARTY_AC_REGISTER",
List.of("Party Code", "Party Name", "Agent Code", "Opening Date", "Close Date", "Final Balance"), rows);
}
/**
* The legacy Master Data Export report: clarified with the business (2026-08-24) as "select an
* agent, show the full detail of every party under that agent" - filtered by the same agent
* range as every other report here (a single agent is just agentFrom == agentTo).
*/
private DdsReportData masterDataExport(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "p.party_code as \"Party Code\", p.party_name as \"Party Name\", p.address1 as \"Address\", "
+ "p.city as \"City\", p.pincode as \"Pincode\", p.mobile_no_sms as \"Mobile No\", "
+ "p.aadhar_no as \"Aadhar No\", p.pan_card_no as \"PAN No\", p.opening_date as \"Opening Date\", "
+ "p.maturity_date as \"Maturity Date\", p.daily_deposit_amount as \"Daily Deposit Amount\", "
+ "p.interest_rate as \"Interest Rate\", p.current_balance as \"Current Balance\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where a.id between ? and ? and p.id between ? and ? order by a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo);
return available("MASTER_DATA_EXPORT",
List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Address", "City", "Pincode", "Mobile No", "Aadhar No",
"PAN No", "Opening Date", "Maturity Date", "Daily Deposit Amount", "Interest Rate", "Current Balance"),
rows);
}
private DdsReportData available(final String reportKey, final List<String> columns, final List<Map<String, Object>> rows) {
return DdsReportData.builder().reportKey(reportKey).available(true).columns(columns).rows(normaliseDates(rows)).build();
}
/**
* Converts the JDBC date types every row arrives with into the java.time types the API can
* actually render.
*
* {@code queryForList} hands back {@link java.sql.Date} and {@link java.sql.Timestamp}, and
* serialising either of those throws - so until this ran, every report carrying a date column
* failed outright with a 500-shaped error while the ones without dates worked. That was ten of
* the eighteen reports, including Commission Register, Party Interest Register and Mature Date
* Wise.
*
* Done here, in the one place every report's rows pass through, rather than in each query: a
* report added later gets the same treatment without having to know about this.
*/
private List<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;
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.starter;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformServiceImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsReportConfiguration {
@Bean
@ConditionalOnMissingBean(DdsReportReadPlatformService.class)
public DdsReportReadPlatformService ddsReportReadPlatformService(JdbcTemplate jdbcTemplate) {
return new DdsReportReadPlatformServiceImpl(jdbcTemplate);
}
}
@@ -0,0 +1,95 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/partytransactions")
@Component
@Tag(name = "DDS Party Transactions", description = "The Daily Transaction Entry ledger: a day's PARTY_TRAN collection rows, the two legs of a WITHDRAW_DEPOSITE transfer between two parties, or an AGENT_TRAN settlement of an Agent's collected cash to its branch. Immutable once posted - reversed with an opposing entry, never edited.")
@RequiredArgsConstructor
public class PartyTransactionsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTYTRANSACTION";
private final PlatformSecurityContext context;
private final PartyTransactionReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Party Transactions", description = "Returns the list of posted DDS party transactions, most recent first. Optionally filter by agentId and/or partyId.")
public Collection<PartyTransactionData> retrieveAll(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
@QueryParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll(agentId, partyId);
}
@GET
@Path("{transactionId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Party Transaction", description = "Returns the details of a single posted DDS party transaction.")
public PartyTransactionData retrieveOne(@PathParam("transactionId") @Parameter(description = "transactionId") final Long transactionId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(transactionId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post DDS Party Transactions", description = "Mandatory fields: agentId, transactionType (PARTY_TRAN, "
+ "WITHDRAW_DEPOSITE or AGENT_TRAN), transactionDate\n" + "\n"
+ "For PARTY_TRAN: collections (array of {partyCode, transactionMode, accountType, accountNo, amount, chequeNo, tokenNo, "
+ "description})\n" + "\n" + "For WITHDRAW_DEPOSITE: creditorPartyCode, debitPartyCode, withdrawAmount, withdrawDescription\n"
+ "\n" + "For AGENT_TRAN: amount, transactionMode, accountType, accountNo, description")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createPartyTransaction() //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_TRANSFER, DdsVoucherService.REF_AGENT,
DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId"),
() -> DdsVoucherService.numberIn(apiRequestBodyAsJson, "withdrawAmount"),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
}
@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PartyTransactionRepository extends JpaRepository<PartyTransaction, Long> {
@Query("select coalesce(sum(t.amount), 0) from PartyTransaction t where t.agent.id = :agentId "
+ "and t.transactionType = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transactionDate between :fromDate and :toDate")
BigDecimal sumPartyTranCredits(@Param("agentId") Long agentId, @Param("fromDate") LocalDate fromDate,
@Param("toDate") LocalDate toDate);
/**
* What one account has been collected in total against its instalments.
*
* Only PARTY_TRAN counts. Interest is the bank's to pay and a transfer in from another party is
* not this customer keeping to their schedule; counting either would show an account as up to date
* on days nobody collected. The collection screen's own figure is read the same way, so the two
* cannot disagree.
*/
@Query("select coalesce(sum(t.amount), 0) from PartyTransaction t where t.party.id = :partyId "
+ "and t.transactionType = 'PARTY_TRAN' and t.direction = 'CREDIT'")
BigDecimal sumCollectedFor(@Param("partyId") Long partyId);
/**
* Every movement on these accounts up to the given date, oldest first.
*
* Interest on a daily-deposit account cannot be worked out from the closing balance alone: the
* money arrives a little at a time, so each instalment has to earn over the days it was actually
* held. That means replaying the account's movements, which is what these rows are for. Fetched
* for the whole batch in one query and grouped in memory, rather than once per account.
*/
@Query("select t from PartyTransaction t where t.party.id in :partyIds and t.transactionDate <= :toDate "
+ "order by t.party.id, t.transactionDate, t.id")
List<PartyTransaction> findForPartiesUpTo(@Param("partyIds") Collection<Long> partyIds, @Param("toDate") LocalDate toDate);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTYTRANSACTION", action = "CREATE")
public class CreatePartyTransactionCommandHandler implements NewCommandSourceHandler {
private final PartyTransactionWritePlatformService writePlatformService;
@Autowired
public CreatePartyTransactionCommandHandler(final PartyTransactionWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createPartyTransaction(command);
}
}
@@ -0,0 +1,210 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* PARTY_TRAN (batch collection rows), WITHDRAW_DEPOSITE (a transfer between two parties) and
* AGENT_TRAN (an Agent settling its collected cash to a branch, no party involved) are
* validated/processed - the three transaction types the Daily Transaction Entry screen actually
* collects fields for today. PARTY_PAYMENT / OD_ENTRY / OD_PAYMENT are listed on the frontend as
* not-yet-implemented placeholders, so they are rejected here rather than silently accepted with no
* real effect.
*/
@Component
public final class PartyTransactionCommandFromApiJsonDeserializer {
/**
* A List, not a Set, on purpose: it selects DataValidatorBuilder's List overload of
* isOneOfTheseStringValues, which lowercases both sides before comparing. The varargs overload
* lowercases only the submitted value, so an upper-case set like this one never matches and
* every request is rejected. Ordering is fixed too, so the error message reads the same each time.
*/
private static final List<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", "advanceDays", "arrearsDays", "chequeNo", "tokenNo", "description"));
/**
* The array a collection row sits in. Named once because every error raised against a row has to
* carry it: the validator builds "collections[0][accountNo]" from the array name and the field
* name together, so a field named without its array reads back as "null[0][accountNo]".
*/
private static final String COLLECTIONS = "collections";
private static final String TRANSACTION_MODE = "transactionMode";
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public PartyTransactionCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("partytransaction");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
final String transactionType = this.fromApiJsonHelper.extractStringNamed("transactionType", element);
v.reset().parameter("transactionType").value(transactionType).notBlank()
.isOneOfTheseStringValues(SUPPORTED_TRANSACTION_TYPES);
v.reset().parameter("transactionDate").value(this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element)).notNull();
if ("PARTY_TRAN".equals(transactionType)) {
if (!this.fromApiJsonHelper.parameterExists(COLLECTIONS, element)) {
v.reset().parameter(COLLECTIONS).value(null).notNull();
} else {
final JsonObject topLevel = element.getAsJsonObject();
final var collectionsArray = topLevel.getAsJsonArray(COLLECTIONS);
v.reset().parameter(COLLECTIONS).value(collectionsArray).jsonArrayNotEmpty();
for (int i = 0; i < collectionsArray.size(); i++) {
final JsonObject row = collectionsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(row, SUPPORTED_COLLECTION_PARAMETERS);
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("partyCode", i).value(extractString(row, "partyCode"))
.notBlank();
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("accountNo", i).value(extractString(row, "accountNo"))
.notBlank();
// Which way the money came in, on the row it came in on. Checked per row rather
// than once for the batch because a clerk settles a morning's round in one go,
// and the rounds are mixed - three in cash, one by cheque.
final String rowMode = extractString(row, TRANSACTION_MODE);
v.reset().parameter(COLLECTIONS).parameterAtIndexArray(TRANSACTION_MODE, i).value(rowMode).notBlank();
// The row has to be named in the message itself. A pattern failure carries only
// the parameter, never the row it came from, so on a round of twenty rows the
// clerk would otherwise be told a mode is wrong without being told which one -
// the same reason the advance/arrears clash below counts the row out by hand.
v.reset().parameter(COLLECTIONS).value(rowMode).ignoreIfNull().matchesRegularExpression(
DdsTransactionMode.acceptedPattern(),
"Row " + (i + 1) + ": payment mode must be one of " + DdsTransactionMode.labelsForMessage());
// A row is one of three things: a plain collection, where the clerk types what was
// handed over; days taken in advance; or days of arrears being cleared. All three
// carry an amount in the end, but the day-based two have theirs worked out from
// the scheme rather than typed, and both are capped - the amount is checked against
// the account itself in the write service, which is where the account is loaded.
//
// A mistyped figure on a plain row is the clerk's to explain and gets no more than
// a warning on screen; paying ahead and reaching back are things the bank limits.
final Integer advanceDays = extractInteger(row, "advanceDays");
final Integer arrearsDays = extractInteger(row, "arrearsDays");
if (advanceDays != null && arrearsDays != null) {
// failWithCode carries only the parameter, never the row - so the row is passed
// as a message argument instead, counted from one the way a clerk reads a list.
v.reset().parameter(COLLECTIONS).failWithCode("cannot.be.both.advance.and.arrears", i + 1);
}
if (advanceDays != null) {
// Both, because the range check names the parameter while the array helpers
// only carry the row it came from - set alone, the message reads "`null`".
v.reset().parameter("advanceDays").parameterAtIndexArray("advanceDays", i).value(advanceDays)
.inMinMaxRange(1, DdsSchedule.MAX_ADVANCE_DAYS);
} else if (arrearsDays != null) {
v.reset().parameter("arrearsDays").parameterAtIndexArray("arrearsDays", i).value(arrearsDays)
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
} else {
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
.notNull().positiveAmount();
}
}
}
} else if ("WITHDRAW_DEPOSITE".equals(transactionType)) {
v.reset().parameter("creditorPartyCode").value(this.fromApiJsonHelper.extractStringNamed("creditorPartyCode", element))
.notBlank();
v.reset().parameter("debitPartyCode").value(this.fromApiJsonHelper.extractStringNamed("debitPartyCode", element))
.notBlank();
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
.notNull().positiveAmount();
} else if ("AGENT_TRAN".equals(transactionType)) {
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
.positiveAmount();
// An agent handing its round's takings to the branch is a counter transaction like any
// other, so it says how the money arrived on the same terms a collection does.
final String settlementMode = this.fromApiJsonHelper.extractStringNamed(TRANSACTION_MODE, element);
v.reset().parameter(TRANSACTION_MODE).value(settlementMode).notBlank();
v.reset().parameter(TRANSACTION_MODE).value(settlementMode).ignoreIfNull().matchesRegularExpression(
DdsTransactionMode.acceptedPattern(), "Payment mode must be one of " + DdsTransactionMode.labelsForMessage());
}
// WITHDRAW_DEPOSITE deliberately carries no mode: it moves a balance from one DDS account to
// another inside the bank's own books. Nothing crosses the counter, so there is nothing for a
// mode to describe, and asking for one would only invite a meaningless "Cash".
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private Integer extractInteger(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsInt() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData;
public interface PartyTransactionReadPlatformService {
Collection<PartyTransactionData> retrieveAll(Long agentId, Long partyId);
PartyTransactionData retrieveOne(Long transactionId);
}
@@ -0,0 +1,99 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.security.utils.SQLBuilder;
import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyTransactionNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class PartyTransactionReadPlatformServiceImpl implements PartyTransactionReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final PartyTransactionMapper MAPPER = new PartyTransactionMapper();
private static final class PartyTransactionMapper implements RowMapper<PartyTransactionData> {
public String schema() {
return " t.id as id, t.party_id as partyId, p.party_code as partyCode, p.party_name as partyName, "
+ " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, "
+ " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, "
+ " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, "
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo "
+ " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
+ " join m_dds_agent a on a.id = t.agent_id";
}
@Override
public PartyTransactionData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return PartyTransactionData.builder() //
.id(rs.getLong("id")) //
.partyId(JdbcSupport.getLongDefaultToNullIfZero(rs, "partyId")) //
.partyCode(rs.getString("partyCode")) //
.partyName(rs.getString("partyName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.transactionDate(JdbcSupport.getLocalDate(rs, "transactionDate")) //
.transactionType(rs.getString("transactionType")) //
.direction(rs.getString("direction")) //
.transactionMode(rs.getString("transactionMode")) //
.accountType(rs.getString("accountType")) //
.accountNo(rs.getString("accountNo")) //
.amount(rs.getBigDecimal("amount")) //
.chequeNo(rs.getString("chequeNo")) //
.tokenNo(rs.getString("tokenNo")) //
.description(rs.getString("description")) //
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
.voucherNo(rs.getString("voucherNo")) //
.build();
}
}
@Override
public Collection<PartyTransactionData> retrieveAll(final Long agentId, final Long partyId) {
final SQLBuilder extraCriteria = new SQLBuilder();
extraCriteria.addNonNullCriteria(" t.agent_id = ", agentId);
extraCriteria.addNonNullCriteria(" t.party_id = ", partyId);
String sql = "select " + MAPPER.schema() + " " + extraCriteria.getSQLTemplate();
sql = sql + " order by t.id desc";
return this.jdbcTemplate.query(sql, MAPPER, extraCriteria.getArguments()); // NOSONAR
}
@Override
public PartyTransactionData retrieveOne(final Long transactionId) {
try {
final String sql = "select " + MAPPER.schema() + " where t.id = ?";
return this.jdbcTemplate.queryForObject(sql, MAPPER, transactionId); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new PartyTransactionNotFoundException(transactionId, e);
}
}
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
public interface PartyTransactionWritePlatformService {
CommandProcessingResult createPartyTransaction(JsonCommand command);
/**
* Posts one collection: the customer is credited, the agent's cash goes up, and both the DDS
* ledger and the bank's books are written.
*
* Exposed because a written-down entry becomes a real credit through exactly this, one entry at
* a time, when a clerk settles an agent's day. The alternative was a second implementation of
* the same posting, and two postings of the same thing drift.
*
* It posts what it is given and checks nothing: whether this party may be collected from by this
* agent, and whether the amount is a permitted one, is settled before the entry is written down
* and again before it is settled. Callers that have not established those have no business
* calling this.
*/
PartyTransaction postSingleCollection(Agent agent, Party party, LocalDate transactionDate, BigDecimal amount, String transactionMode,
String accountType, String accountNo, String chequeNo, String tokenNo, String description);
}
@@ -0,0 +1,388 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.exception.PartyHeldUnderLienException;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientAgentBalanceException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientPartyBalanceException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyNotCollectedByAgentException;
import org.apache.fineract.portfolio.dds.transaction.serialization.PartyTransactionCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements PartyTransactionWritePlatformService {
private final PartyTransactionCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final PartyTransactionRepository partyTransactionRepository;
private final PartyRepository partyRepository;
private final AgentRepository agentRepository;
/** Puts each movement onto the bank's own books as well as DDS's - see DdsLedger. */
private final DdsLedger ddsLedger;
/** The bank's own working-days setting - which days the branch opens. See bankingDateFor. */
private final WorkingDaysRepositoryWrapper workingDaysRepository;
@Transactional
@Override
public CommandProcessingResult createPartyTransaction(final JsonCommand command) {
// One DDS voucher for the entry - a transfer names both accounts even though no ledger leg moves.
final String voucherType = "WITHDRAW_DEPOSITE".equals(command.stringValueOfParameterNamed("transactionType"))
? DdsVoucherService.TYPE_TRANSFER
: DdsVoucherService.TYPE_TRANSACTION;
return this.ddsLedger.withVoucher(voucherType, DdsVoucherService.REF_AGENT, command.longValueOfParameterNamed("agentId"),
() -> createWithinVoucher(command));
}
private CommandProcessingResult createWithinVoucher(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long agentId = command.longValueOfParameterNamed("agentId");
final Agent agent = this.agentRepository.findById(agentId)
.orElseThrow(() -> new AgentNotFoundException(agentId));
final String transactionType = command.stringValueOfParameterNamed("transactionType");
// The day it reaches the bank's books, which is the collection day itself unless the
// branch was shut that day - see bankingDateFor.
final LocalDate transactionDate = bankingDateFor(command.localDateValueOfParameterNamed("transactionDate"));
final List<PartyTransaction> posted = new ArrayList<>();
if ("PARTY_TRAN".equals(transactionType)) {
posted.addAll(postCollections(agent, transactionDate, command));
} else if ("AGENT_TRAN".equals(transactionType)) {
posted.add(postAgentSettlement(agent, transactionDate, command));
} else {
posted.addAll(postWithdrawDeposite(agent, transactionDate, command));
}
this.partyTransactionRepository.saveAllAndFlush(posted);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(posted.isEmpty() ? null : posted.get(0).getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
/**
* The day a collection reaches the bank's books, which is not always the day it was collected.
*
* The agent calls every day of the scheme, weekends included, so money is taken on days the branch
* does not open. That money is not banked until the branch next opens, and the entry belongs on
* that day - posting it on the closed day would put DDS's ledger on a date the bank's own books
* have nothing on, and show cash reaching the branch before it did.
*
* The collection itself is never refused. A Saturday's takings are entered and simply carry
* Monday's date, along with Sunday's and Monday's own.
*
* Both which days the branch opens and what to do about a date that falls outside them are the
* bank's own working-days setting, read here rather than assumed - a bank that opens on Saturdays
* changes it there and nothing moves. Today that setting names all seven days, so nothing shifts
* at all; this earns its place the day the bank closes at the weekend.
*/
private LocalDate bankingDateFor(final LocalDate collectionDate) {
if (collectionDate == null) {
return null;
}
final WorkingDays workingDays = this.workingDaysRepository.findOne();
if (workingDays == null) {
return collectionDate;
}
final LocalDate banked = WorkingDaysUtil.getOffSetDateIfNonWorkingDay(collectionDate, null, workingDays);
// The setting can name a rule this has no meeting date to answer - fall back to the day given
// rather than posting against nothing.
return banked == null ? collectionDate : banked;
}
/**
* What this row actually collects.
*
* Usually the figure the clerk typed, being what the customer handed over. Where the row says
* days taken in advance instead, the amount is the scheme's own - the daily instalment times the
* days - and is worked out here rather than trusted from the request: the screen shows the
* customer a figure before they pay, and the two have to be the same figure.
*
* A row may carry both, and then they must agree. A mismatch means the screen quoted one amount
* and the request asked to post another, which is worth stopping over rather than silently
* preferring one.
*/
private BigDecimal amountOf(final JsonObject row, final Party party) {
final Integer days = wholeOf(row, "advanceDays") != null ? wholeOf(row, "advanceDays") : wholeOf(row, "arrearsDays");
if (days == null) {
return row.get("amount").getAsBigDecimal();
}
final boolean isAdvance = wholeOf(row, "advanceDays") != null;
final BigDecimal daily = party.getDailyDepositAmount();
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
throw new PartyHasNoDailyAmountException(party.getPartyCode());
}
// Arrears are capped by what this account actually still owes inside the catch-up window, not
// by a flat number of days: an account one day behind cannot pay three off, and an account
// whose missed days have fallen outside the window cannot pay them at all. The screen offers
// only what is allowed, but the screen is not the only way in.
if (!isAdvance) {
final int allowed = scheduleOf(party).recoverableDays(daily);
if (days > allowed) {
throw new PartyArrearsDaysExceededException(party.getPartyCode(), days, allowed);
}
}
final BigDecimal dueAmount = daily.multiply(BigDecimal.valueOf(days));
if (row.has("amount") && !row.get("amount").isJsonNull()) {
final BigDecimal quoted = row.get("amount").getAsBigDecimal();
if (quoted.compareTo(dueAmount) != 0) {
throw new PartyAdvanceAmountMismatchException(party.getPartyCode(), days, dueAmount, quoted, isAdvance);
}
}
return dueAmount;
}
private Integer wholeOf(final JsonObject row, final String field) {
return row.has(field) && !row.get(field).isJsonNull() ? row.get(field).getAsInt() : null;
}
/**
* Where this account stands today - the same figures the collection screen shows, from the same
* calculation, so a clerk is never quoted an amount the server then refuses.
*/
private DdsSchedule scheduleOf(final Party party) {
final int due = DdsSchedule.instalmentsDue(party.getOpeningDate(), party.getDays(), DateUtils.getBusinessLocalDate());
final BigDecimal collected = this.partyTransactionRepository.sumCollectedFor(party.getId());
return DdsSchedule.of(party.getDailyDepositAmount(), due, collected);
}
private List<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 = partyOf(partyCode, agent);
final BigDecimal amount = amountOf(row, party);
transactions.add(postSingleCollection(agent, party, transactionDate, amount, stringOrNull(row, "transactionMode"),
stringOrNull(row, "accountType"), stringOrNull(row, "accountNo"), stringOrNull(row, "chequeNo"),
stringOrNull(row, "tokenNo"), stringOrNull(row, "description")));
}
return transactions;
}
/**
* Credits one collection to a customer and puts the cash in the agent's hands, on DDS's ledger
* and on the bank's.
*
* Held apart from the round that calls it because a second caller now settles an agent's
* written-down day, one entry at a time, and the two must post identically. Anything that lived
* only in the loop above would be a thing the settled entries silently did without - and the
* difference would show up as a customer credited by one route and not the other.
*/
@Override
public PartyTransaction postSingleCollection(final Agent agent, final Party party, final LocalDate transactionDate,
final BigDecimal amount, final String transactionMode, final String accountType, final String accountNo,
final String chequeNo, final String tokenNo, final String description) {
final BigDecimal newBalance = party.credit(amount);
agent.credit(amount);
// The same money on the bank's own books: onto what the customer is owed - their savings
// account where they still have one, DDS Customer Deposits otherwise - and into the agent's
// cash until the agent hands it over.
this.ddsLedger.collect(party, transactionDate, amount, "DDS collection by " + agent.getAgentCode(), agent.getGlAccount());
// Saved as it is made, rather than the whole round together at the end.
//
// A statement is read in id order, and each row carries the balance as it stood when that
// row was applied. Saving the round in one call leaves the ids to Hibernate, which is free
// to write them in any order it likes - and it does: a round of three came back with the
// later collection holding the lower id, so the passbook read as though the balance fell
// after a deposit. The figures were right and the story they told was not.
//
// One insert per collection instead of one per round is the price, and a round is tens of
// rows rather than thousands.
return this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent, transactionDate, "PARTY_TRAN", "CREDIT",
DdsTransactionMode.canonicalOrOriginal(transactionMode), accountType, accountNo, amount, chequeNo, tokenNo, description,
newBalance));
}
private PartyTransaction postAgentSettlement(final Agent agent, final LocalDate transactionDate, final JsonCommand command) {
// An agent's cash is taken in at the cashier's counter now - Cashier > Transaction Entry, a GL
// receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent
// Tran > Apply DDS Receipt. Taking the same cash in here as well would be a second way in for
// one bag of notes, so this way is closed, and says where the right one is.
if (agent != null) {
throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier",
"An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry "
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent.getGlAccount() != null ? agent.getGlAccount().getGlCode()
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
+ "Apply DDS Receipt.");
}
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
throw new InsufficientAgentBalanceException(agent.getAgentCode());
}
final BigDecimal newBalance = agent.settle(amount);
// The cash reaches the branch. No customer is involved - they were credited when their money
// was collected; this is the bank catching up with where the notes actually are.
this.ddsLedger.settle(agent.getOffice(), transactionDate, amount, "DDS settlement by " + agent.getAgentCode(), agent.getGlAccount());
return new PartyTransaction(null, agent, transactionDate, "AGENT_TRAN", "DEBIT",
DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")),
command.stringValueOfParameterNamedAllowingNull("accountType"),
command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null,
command.stringValueOfParameterNamedAllowingNull("description"), newBalance);
}
private List<PartyTransaction> postWithdrawDeposite(final Agent agent, final LocalDate transactionDate,
final JsonCommand command) {
final String creditorPartyCode = command.stringValueOfParameterNamed("creditorPartyCode");
final String debitPartyCode = command.stringValueOfParameterNamed("debitPartyCode");
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("withdrawAmount");
final String description = command.stringValueOfParameterNamedAllowingNull("withdrawDescription");
// Money moved from an account into itself is no transfer at all - just two lines that cancel.
if (creditorPartyCode != null && creditorPartyCode.trim().equalsIgnoreCase(String.valueOf(debitPartyCode).trim())) {
final String message = "Pick two different accounts - " + creditorPartyCode.trim().toUpperCase()
+ " cannot transfer to itself.";
throw new PlatformApiDataValidationException("error.msg.dds.transfer.same.party", message,
List.of(ApiParameterError.generalError("error.msg.dds.transfer.same.party", message)));
}
final Party creditorParty = partyOf(creditorPartyCode, agent);
final Party debitParty = partyOf(debitPartyCode, agent);
// Held money cannot leave by this door either. A transfer to another party is the third way
// out of an account - beside the maturity payout and an early closure, both of which already
// refuse it - and leaving it open would let a held deposit be moved to an account that is
// not held, which is the lien undone in one step.
if (debitParty.isLien()) {
throw new PartyHeldUnderLienException(debitParty.getPartyCode(), debitParty.getLienReason());
}
if (debitParty.getCurrentBalance().compareTo(amount) < 0) {
throw new InsufficientPartyBalanceException(debitPartyCode);
}
final BigDecimal debtorNewBalance = debitParty.debit(amount);
final BigDecimal creditorNewBalance = creditorParty.credit(amount);
// The same move on the bank's own books, wherever each party's account is kept. Two DDS
// accounts are both owed on DDS Customer Deposits, so between them nothing changes account.
this.ddsLedger.transfer(debitParty, creditorParty, transactionDate, amount,
description == null ? "DDS transfer " + debitPartyCode + " to " + creditorPartyCode : description);
// Each side's passbook line says where the money went or came from - a line reading only
// "Transfer" leaves the customer asking whose account took their money.
final String note = description == null || description.isBlank() ? "" : " - " + description;
final List<PartyTransaction> transactions = new ArrayList<>();
transactions.add(new PartyTransaction(debitParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "DEBIT", null, "DDS",
creditorParty.getAccountNo(), amount, null, null,
"Transfer to " + creditorPartyCode + " · " + creditorParty.getPartyName() + note, debtorNewBalance));
transactions.add(new PartyTransaction(creditorParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "CREDIT", null, "DDS",
debitParty.getAccountNo(), amount, null, null,
"Transfer from " + debitPartyCode + " · " + debitParty.getPartyName() + note, creditorNewBalance));
return transactions;
}
/**
* Finds the party a transaction names, and refuses it unless this agent actually collects for
* them and the account is still open.
*
* The screen keeps a clerk to the right parties by only offering that agent's own in the
* dropdown, but Withdraw Deposite takes party codes as free text and the API can be called
* directly either way - so the rule belongs here, where nothing can go around it.
*/
private Party partyOf(final String partyCode, final Agent agent) {
final Party party = this.partyRepository.findByPartyCode(partyCode)
.orElseThrow(() -> new PartyNotFoundException(partyCode));
if (party.isClosed()) {
throw new PartyAlreadyClosedException(partyCode);
}
if (!party.getAgent().getId().equals(agent.getId())) {
throw new PartyNotCollectedByAgentException(partyCode, agent.getAgentCode());
}
return party;
}
private String stringOrNull(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("party_code")) {
throw new PlatformDataIntegrityException("error.msg.partytransaction.invalid.party",
"One of the party codes in this transaction does not exist");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.partytransaction.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.starter;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.dds.transaction.serialization.PartyTransactionCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformService;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformService;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionWritePlatformServiceJpaRepositoryImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsPartyTransactionConfiguration {
@Bean
@ConditionalOnMissingBean(PartyTransactionReadPlatformService.class)
public PartyTransactionReadPlatformService partyTransactionReadPlatformService(JdbcTemplate jdbcTemplate) {
return new PartyTransactionReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(PartyTransactionWritePlatformService.class)
public PartyTransactionWritePlatformService partyTransactionWritePlatformService(
PartyTransactionCommandFromApiJsonDeserializer fromApiJsonDeserializer, PartyTransactionRepository partyTransactionRepository,
PartyRepository partyRepository, AgentRepository agentRepository, DdsLedger ddsLedger,
WorkingDaysRepositoryWrapper workingDaysRepository) {
return new PartyTransactionWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyTransactionRepository,
partyRepository, agentRepository, ddsLedger, workingDaysRepository);
}
}

Some files were not shown because too many files have changed in this diff Show More