Merge origin/dev-latest into feature/investment

Bring in the latest team changes (FD interest posting approval, recurring
deposit rules, tenant setup, cash exchange PAN, and others).
final-changelog-tenant.xml conflicted because both sides appended
includes: keep the team's includes first, then the investment
0593-0597 includes. File names differ, so the changesets do not clash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 10:44:29 +05:30
co-authored by Claude Opus 5.5
364 changed files with 35075 additions and 1705 deletions
@@ -60,6 +60,7 @@ import org.apache.fineract.infrastructure.core.api.DateParam;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.DateFormat;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -229,6 +230,11 @@ public class JournalEntriesApiResource {
@PathParam("transactionId") @Parameter(description = "transactionId") final String transactionId,
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
CommandProcessingResult result;
if (is(commandParam, "reverse")) {
throw new PlatformDataIntegrityException("error.msg.journalentry.reverse.not.allowed",
"Reversing a journal entry is not allowed.");
}
if (is(commandParam, "reverse")) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().reverseJournalEntry(transactionId).withJson(jsonRequestBody)
.build();
@@ -650,7 +650,7 @@ public class AccountingProcessorHelper {
*/
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
@@ -665,10 +665,14 @@ public class AccountingProcessorHelper {
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring Deposit Interest Payable",
"Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
// The RD product screen saves its paid account as "Recurring Deposit Interest Paid"; without that name here an
// RD's interest skipped the paid leg even when the product had one mapped.
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
"Interest Paid Account"))
.orElse(payableAccount);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
@@ -706,6 +710,19 @@ public class AccountingProcessorHelper {
// debit balance would come back negative and every posting would raise a shortfall larger
// than the interest itself.
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
if (!payableCarriesAccrual) {
// Cash basis: nothing ever accrues into the payable, so whatever already sits on it - an opening balance
// brought over at migration, or another product's interest sharing the GL - is not this interest. Reading
// it as "already accrued" skipped the expense and drew that balance down instead, leaving the cost off the
// P&L. The whole amount is charged and raised, and a reversal or clawback takes it back the same way.
if (payableIsLiability) {
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId,
transactionDate, amount, isReversal);
return;
}
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
@@ -718,6 +735,14 @@ public class AccountingProcessorHelper {
shortfall, false);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
/** Stages 2 and 3: out of the payable, through the paid account when the product names one, into the member. */
private void postPayableToMember(final Office office, final String currencyCode, final GLAccount payableAccount,
final GLAccount paidAccount, final GLAccount controlAccount, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
@@ -194,7 +194,7 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
}
}
@@ -186,7 +186,7 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
}
}
@@ -687,6 +687,16 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
}
private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command) {
validateBusinessRulesForJournalEntries(command, true);
}
/**
* @param requireBalanced
* false for opening balances: every leg is posted against the contra account, so the books always
* balance, and the submitted legs legitimately differ by the locked (denomination-managed) cash
* account's opening balance that is already posted and can never be part of this request.
*/
private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command, final boolean requireBalanced) {
// check if date of Journal entry is valid
final LocalDate transactionDate = command.getTransactionDate();
// shouldn't be in the future
@@ -706,6 +716,26 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final SingleDebitOrCreditEntryCommand[] credits = command.getCredits();
final SingleDebitOrCreditEntryCommand[] debits = command.getDebits();
if (!requireBalanced) {
final List<SingleDebitOrCreditEntryCommand> entries = new ArrayList<>();
if (credits != null) {
entries.addAll(Arrays.asList(credits));
}
if (debits != null) {
entries.addAll(Arrays.asList(debits));
}
if (entries.isEmpty()) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null);
}
for (final SingleDebitOrCreditEntryCommand entry : entries) {
if (entry.getAmount() == null || entry.getGlAccountId() == null) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, null, null,
null);
}
}
return;
}
// atleast one debit or credit must be present
if (credits == null || credits.length == 0 || debits == null || debits.length == 0) {
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null);
@@ -783,7 +813,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
validateBusinessRulesForJournalEntries(journalEntryCommand);
validateBusinessRulesForJournalEntries(journalEntryCommand, false);
validateNoDenominationManagedCashAccounts(journalEntryCommand);
/**
@@ -84,4 +84,8 @@ public final class LedgerTxnPrefix {
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
// ── Fixed deposits ────────────────────────────────────────────────────────
/** Approved fixed deposit interest posting report, for one product, branch and date. */
public static final String FD_INTEREST_POSTED = "FDINT";
}
@@ -294,8 +294,14 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
: null;
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
@@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return property.isEnabled();
}
/**
* Read on every run of the interest posting job and of the day close, so the two paths can never
* both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid
* from an approved report, with it off the job pays them directly as it always did.
*/
@Override
public boolean isFixedDepositInterestPostingApprovalEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled();
}
@Override
public Integer retrieveFinancialYearBeginningMonth() {
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
private final TenantDataSourceFactory tenantDataSourceFactory;
private final Environment environment;
private final TenantInitialUserProvisioner initialUserProvisioner;
// DO NOT REMOVE! Required for liquibase custom task initialization
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
// configuration
// settings, and we should not use them to control the script order
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
if (brandNewTenant) {
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
}
}
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
tenant.getTenantIdentifier());
@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service.migration;
import java.util.Locale;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.useradministration.service.AppUserConstants;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
* public default password).
*
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
* break validation on every tenant that already ran it.
*
* Usernames become {@code <tenantIdentifier>_admin} and {@code <tenantIdentifier>_interop}. The system user keeps the
* {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it
* up by that name; only its password is changed. All three share one password: the value of
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
* otherwise {@link #DEFAULT_PASSWORD}.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantInitialUserProvisioner {
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
private static final long ADMIN_ID = 1L;
private static final long SYSTEM_ID = 2L;
private static final long INTEROP_ID = 3L;
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
private final Environment environment;
public void provision(final DataSource dataSource, final String tenantIdentifier) {
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
final String prefix = usernamePrefix(tenantIdentifier);
final String configured = environment.getProperty(PASSWORD_PROPERTY);
final boolean overridden = configured != null && !configured.isBlank();
final String password = overridden ? configured : DEFAULT_PASSWORD;
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
final String encoded = encoder.encode(password);
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded);
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.",
tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
}
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
encodedPassword, id);
}
static String usernamePrefix(final String tenantIdentifier) {
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
return cleaned.isEmpty() ? "tenant" : cleaned;
}
}
@@ -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);
}
}
@@ -27,6 +27,7 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
import com.google.common.collect.Lists;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -44,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -74,6 +76,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@@ -149,6 +152,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
@@ -204,6 +208,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -409,6 +414,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
toSavingsAccount, withdrawal, deposit);
@@ -454,6 +460,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
toSavingsAccount, deposit, loanTransaction);
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
@@ -488,6 +495,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
@@ -649,6 +657,26 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
return accountTransferDetails;
}
/**
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
* is left untouched.
*/
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
return;
}
final boolean isPreMatureClosure = false;
recurringDeposit.updateDepositAmount(deposit.getAmount());
recurringDeposit.handleScheduleInstallments(deposit);
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
}
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
}
@@ -857,6 +885,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -48,6 +48,8 @@ import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -91,6 +93,8 @@ public class BranchDayRegisterApiResource {
private final VaultPositionService vaultPositionService;
private final SavingsInterestPostingReportService interestPostingReportService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
@@ -247,6 +251,16 @@ public class BranchDayRegisterApiResource {
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
// What will stop this close, so the screen can say so before the button is pressed rather than
// after. The same lists closeDay() refuses on, read-only here.
result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate));
// One call for the deposits: what is blocking, and the products and amounts behind it, so the
// branch sees what it is being asked to approve without leaving the close screen. Pricing a
// branch's deposits is the expensive part and is done once.
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
result.put("fdInterestPending", fdInterest.get("pending"));
result.put("fdInterestDue", fdInterest.get("due"));
return gson.toJson(result);
}
@@ -625,6 +639,17 @@ public class BranchDayRegisterApiResource {
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
}
// ── And its fixed deposit interest reports, on the same grounds ──
// Only while deposits are on the approval path at all; with that switched off the posting job
// pays them directly and there is nothing here to approve.
final List<String> fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
if (!fdInterestPending.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending",
"Cannot close the day — this branch's fixed deposit interest posting report is not approved for: "
+ String.join(", ", fdInterestPending)
+ ". Approve it under Reports → FD Interest Posting Reports, then close the day.");
}
// ── The head office closes last ──
// Every branch that opened the day closes first and posts its own interest; a branch that
// never opened is not waited for.
@@ -760,6 +785,23 @@ public class BranchDayRegisterApiResource {
}
}
// ── And the branch's approved fixed deposit interest, the same way ──
// Separate from the savings run so a deposit product with a GL problem cannot stop savings
// interest that has already posted cleanly, and so the refusal names which of the two failed.
final List<Long> fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate));
if (isHeadOffice) {
fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
}
if (!fdToPost.isEmpty()) {
log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size());
final List<String> fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost);
if (!fdFailures.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed",
"Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures)
+ ". The report is still approved; fix the cause and close the day again.");
}
}
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
// this write are two separate statements, so two managers pressing Close at once (or one
// double-click) both got past the check. The second UPDATE then overwrote the first's
@@ -789,11 +831,13 @@ public class BranchDayRegisterApiResource {
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
snapshotVaultDenominations(officeId, registerId, businessDate);
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("zeroBalanceDepositsClosed", depositsClosed);
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
@@ -801,6 +845,38 @@ public class BranchDayRegisterApiResource {
return gson.toJson(res);
}
/**
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
* - is marked Closed on that business date, so it no longer waits in the matured list.
*
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
*
* @return how many deposits were closed
*/
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
final int closed = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
FROM m_savings_product sp, m_client c
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
AND COALESCE(sa.account_balance_derived, 0) = 0
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
if (closed > 0) {
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
}
return closed;
}
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
/**
@@ -16,6 +16,8 @@ import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -29,6 +31,7 @@ import org.apache.fineract.portfolio.denominationmaster.service.DenominationBrea
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
import org.apache.fineract.portfolio.client.data.ClientKycApiConstants;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -50,6 +53,12 @@ public class CashExchangeApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* Above this amount the customer's PAN must be recorded: Income Tax Rule 114B requires PAN for cash
* transactions of more than Rs 50,000 in a day, and RBI cash-handling guidelines follow it.
*/
static final BigDecimal PAN_MANDATORY_ABOVE = new BigDecimal("50000");
@POST
@Transactional
public String create(String body) {
@@ -78,15 +87,25 @@ public class CashExchangeApiResource {
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null;
final String customerName = trimToNull(json, "customerName");
final String customerMobile = trimToNull(json, "customerMobile");
final String panInput = trimToNull(json, "panNumber");
final String panNumber = panInput != null ? panInput.toUpperCase(java.util.Locale.ROOT) : null;
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
: new JsonArray();
final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray();
final BigDecimal receivedTotal = sumTotal(received);
final BigDecimal givenTotal = sumTotal(given);
// Each line is checked against the denomination master and carries the master's value: the amount
// of an exchange is never taken from a value the caller sent.
final Map<Long, Integer> masterValues = denominationValues();
final List<Leg> receivedLegs = parseLegs("received", received, masterValues);
final List<Leg> givenLegs = parseLegs("given", given, masterValues);
if (!hasPositiveCount(received) || !hasPositiveCount(given)) {
final BigDecimal receivedTotal = sumTotal(receivedLegs);
final BigDecimal givenTotal = sumTotal(givenLegs);
if (receivedLegs.isEmpty() || givenLegs.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
"Both received and given denominations are required.");
}
@@ -99,6 +118,19 @@ public class CashExchangeApiResource {
"Both received and given denominations are required.");
}
// PAN is checked whenever it is given, and required above Rs 50,000. The screen enforced the
// threshold on its own, through a free-text "PAN / Aadhaar" box, so an API call -- or any
// text in that box -- recorded a large exchange with no PAN behind it.
if (panNumber != null && !panNumber.matches(ClientKycApiConstants.PAN_REGEX)) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.invalid",
"PAN must be 5 letters, 4 digits and 1 letter (e.g. ABCDE1234F).", "panNumber", panNumber);
}
if (receivedTotal.compareTo(PAN_MANDATORY_ABOVE) > 0 && panNumber == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.required",
"Customer PAN is required for a cash exchange above Rs 50,000 (amount " + receivedTotal + ").", "panNumber",
receivedTotal);
}
CashExchange header = new CashExchange();
header.setAccountId(accountId);
header.setAccountNo(accountNo);
@@ -106,7 +138,12 @@ public class CashExchangeApiResource {
header.setExchangeAmount(receivedTotal);
header.setExchangeDate(exchangeDate);
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
// The Excess Transaction (CTR) report reads customer_id_proof, so the verified PAN fills it when
// nothing else was sent.
header.setCustomerIdProof(customerIdProof != null && !customerIdProof.isBlank() ? customerIdProof : panNumber);
header.setCustomerName(customerName);
header.setCustomerMobile(customerMobile);
header.setPanNumber(panNumber);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
@@ -114,91 +151,48 @@ public class CashExchangeApiResource {
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
// Auto-detect cashier linked to this user
try {
Long cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
header.setCashierId(cashierId);
} catch (Exception ignored) {
// intentionally ignored
}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
// who made the entry is recorded when it can be read
}
// Validate that cashier has enough of each denomination being GIVEN to the customer
if (header.getCashierId() != null) {
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
final java.util.List<String> shortages = new java.util.ArrayList<>();
for (JsonElement el : given) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
long denomId = item.get("denominationId").getAsLong();
long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
// An exchange takes notes into one cashier's drawer and hands change out of it, so only the
// cashier behind this login can record it, and it is always theirs. It used to go through with no
// cashier at all when the login was not a cashier's: the entry then belonged to no drawer, moved no
// cashier's position, and skipped the stock check below.
final Long cashierId = cashierRoleHelper.getCurrentCashierId();
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.cashier.required",
"A cash exchange can only be recorded by a cashier. Your login is not linked to a cashier.");
}
header.setCashierId(cashierId);
// The cashier has to hold, denomination by denomination, what they hand to the customer. A
// denomination is counted across all its lines (parseLegs merges them): checked line by line, two
// lines of 3 against a stock of 5 each passed although 6 were being given.
final Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(cashierId);
final List<String> shortages = new ArrayList<>();
for (final Leg leg : givenLegs) {
final long available = stock.getOrDefault(leg.denominationId(), 0L);
if (leg.count() > available) {
shortages.add("₹" + leg.value() + " (need " + leg.count() + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
}
CashExchange saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), "RECEIVED", received);
saveDetails(saved.getId(), "GIVEN", given);
saveDetails(saved.getId(), "RECEIVED", receivedLegs);
saveDetails(saved.getId(), "GIVEN", givenLegs);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
public String reverse(@PathParam("id") Long id, String body) {
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
CashExchange exchange = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
"Cash exchange " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("accountId") Long accountId,
@@ -238,52 +232,80 @@ public class CashExchangeApiResource {
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
/** One checked line of an exchange: a denomination from the master, the master's value for it, and how many. */
private record Leg(long denominationId, int value, int count) {
}
/** The value of every denomination in the master (a deleted one is gone; a discontinued one is still there). */
private Map<Long, Integer> denominationValues() {
final Map<Long, Integer> values = new HashMap<>();
for (final Map<String, Object> row : jdbcTemplate
.queryForList("SELECT id, denomination FROM m_denomination_master WHERE deleted = false")) {
values.put(((Number) row.get("id")).longValue(), ((Number) row.get("denomination")).intValue());
}
return values;
}
/**
* The lines of one side with a count, each checked against the denomination master and merged per
* denomination. A line must name a known denomination, and a value the caller sent for it has to be the
* master's: with the caller's value trusted, a request could pass a ₹10 note off as ₹500, make the two
* sides "equal", and shift a drawer's books by the difference. A discontinued denomination is accepted --
* a customer can still bring withdrawn notes to be exchanged.
*/
private List<Leg> parseLegs(final String side, final JsonArray items, final Map<Long, Integer> masterValues) {
final Map<Long, Leg> merged = new LinkedHashMap<>();
for (final JsonElement el : items) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.required",
"Every " + side + " line needs a denomination.");
}
final long id = item.get("denominationId").getAsLong();
final Integer value = masterValues.get(id);
if (value == null) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.unknown",
"Unknown denomination (id " + id + ") in the " + side + " notes.");
}
if (item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
&& item.get("denominationValue").getAsInt() != value) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.value.mismatch",
"Denomination " + id + " is ₹" + value + ", not ₹" + item.get("denominationValue").getAsInt() + ".");
}
merged.merge(id, new Leg(id, value, count), (a, b) -> new Leg(id, value, a.count() + b.count()));
}
return new ArrayList<>(merged.values());
}
private BigDecimal sumTotal(final List<Leg> legs) {
BigDecimal total = BigDecimal.ZERO;
for (final Leg leg : legs) {
total = total.add(BigDecimal.valueOf((long) leg.value() * leg.count()));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
private static String trimToNull(final JsonObject json, final String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return null;
}
return false;
final String value = json.get(key).getAsString().trim();
return value.isEmpty() ? null : value;
}
private void saveDetails(Long exchangeId, String direction, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashExchangeDetail detail = new CashExchangeDetail();
private void saveDetails(final Long exchangeId, final String direction, final List<Leg> legs) {
for (final Leg leg : legs) {
final CashExchangeDetail detail = new CashExchangeDetail();
detail.setExchangeId(exchangeId);
detail.setDirection(direction);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detail.setDenominationId(leg.denominationId());
detail.setDenominationValue(leg.value());
detail.setCount(leg.count());
detail.setTotalAmount(BigDecimal.valueOf((long) leg.value() * leg.count()));
detailRepository.save(detail);
}
}
@@ -296,6 +318,9 @@ public class CashExchangeApiResource {
map.put("accountNo", e.getAccountNo());
map.put("clientId", e.getClientId());
map.put("customerIdProof", e.getCustomerIdProof());
map.put("customerName", e.getCustomerName());
map.put("customerMobile", e.getCustomerMobile());
map.put("panNumber", e.getPanNumber());
map.put("exchangeAmount", e.getExchangeAmount());
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
map.put("note", e.getNote());
@@ -52,6 +52,17 @@ public class CashExchange {
@Column(name = "customer_id_proof", length = 50)
private String customerIdProof;
/** Walk-in customer's name; for an existing client, their display name at the time of the exchange. */
@Column(name = "customer_name", length = 150)
private String customerName;
@Column(name = "customer_mobile", length = 20)
private String customerMobile;
/** PAN verified at the counter; mandatory when the exchange is above Rs 50,000. */
@Column(name = "pan_number", length = 10)
private String panNumber;
@Column(name = "performed_by")
private Long performedBy;
@@ -74,6 +74,15 @@ public class CashInterCashierApiResource {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
"From-cashier and To-cashier cannot be the same.");
}
// The cash leaves the sender's drawer, so only the sender can hand it over: the logged-in cashier
// is always the from-cashier. Nobody -- a manager, another cashier -- records a transfer out of
// somebody else's drawer.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(fromCashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.from.must.be.own",
"An inter-cashier transfer can only be made by the cashier handing the cash over, from their own"
+ " drawer.");
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
@@ -92,6 +101,13 @@ public class CashInterCashierApiResource {
"At least one denomination with a positive count is required.");
}
// Neither side can take part before the vault has allocated them cash for the day: a cashier
// who never opened their till has no morning allocation behind their drawer, so they can
// neither hand cash on nor take it in. Checked first so that is the message they see, not a
// shortfall on some denomination.
validateCashAllocated(fromCashierId, transferDate, "from");
validateCashAllocated(toCashierId, transferDate, "to");
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
// stock and settlement validates the cashier's; this register validated neither, so a cashier
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
@@ -134,45 +150,6 @@ public class CashInterCashierApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
CashInterCashier record = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
"Inter-cashier transfer not found: " + id));
if (Boolean.TRUE.equals(record.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
"Inter-cashier transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -209,6 +186,77 @@ public class CashInterCashierApiResource {
return gson.toJson(map);
}
/**
* One cashier's details, today's vault allocation and denomination-wise cash, so the inter-cashier
* screen can show both sides before the transfer is recorded. The Cashier Position endpoint cannot
* serve this: for a plain cashier it ignores {@code cashierId} and returns their own position, so
* the receiving cashier's figures would never show.
*/
@GET
@Path("cashier-summary")
public String cashierSummary(@QueryParam("cashierId") final Long cashierId, @QueryParam("date") final String date) {
context.authenticatedUser().validateHasReadPermission("CASHINTERCASHIER");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.required", "cashierId is required.");
}
final LocalDate asOf = date != null && !date.isBlank() ? LocalDate.parse(date) : DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id AS cashier_id, s.display_name AS staff_name, c.teller_id, t.name AS teller_name,
t.office_id, o.name AS office_name
FROM m_cashiers c
LEFT JOIN m_staff s ON s.id = c.staff_id
LEFT JOIN m_tellers t ON t.id = c.teller_id
LEFT JOIN m_office o ON o.id = t.office_id
WHERE c.id = ?
""", cashierId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.not.found",
"Cashier not found: " + cashierId);
}
final Map<String, Object> row = rows.get(0);
final BigDecimal allocated = allocatedAmount(cashierId, asOf);
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString());
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("cashierId", cashierId);
resp.put("cashierName", row.get("staff_name"));
resp.put("tellerId", row.get("teller_id"));
resp.put("tellerName", row.get("teller_name"));
resp.put("officeId", row.get("office_id"));
resp.put("officeName", row.get("office_name"));
resp.put("asOfDate", asOf.toString());
resp.put("cashAllocated", allocated.signum() > 0);
resp.put("allocatedAmount", allocated);
resp.put("totalAmount", position.get("totalAmount"));
resp.put("denominations", position.get("denominations"));
return gson.toJson(resp);
}
/**
* Cash the vault allocated to the cashier on the date, from the same non-reversed
* {@code m_cash_transfer} rows the "No Cash Allocated" gate on the transaction screens counts.
*/
private BigDecimal allocatedAmount(final Long cashierId, final LocalDate date) {
final BigDecimal amount = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)
AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
""", BigDecimal.class, cashierId, date.toString());
return amount != null ? amount : BigDecimal.ZERO;
}
private void validateCashAllocated(final Long cashierId, final LocalDate date, final String side) {
if (allocatedAmount(cashierId, date).signum() > 0) {
return;
}
final String role = "from".equals(side) ? "sending" : "receiving";
throw new PlatformDataIntegrityException("error.msg.cashintercashier." + side + ".cashier.cash.not.allocated",
"The " + role + " cashier has not been allocated cash from the vault for " + date
+ ". Allocate cash to them through Cash Transfer before an inter-cashier transfer.",
side + "CashierId", cashierId);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
@@ -231,8 +279,8 @@ public class CashInterCashierApiResource {
* Same check, same source of truth and same message shape as
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
* position is left alone rather than refused, matching the settlement path: a cashier with no
* recorded position yet is a setup state, not an overdraft.
* position means the cashier holds nothing: create() only gets here for a cashier the vault has
* allocated cash to, so an empty position is cash already handed on, not a setup state.
*/
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
final LocalDate transferDate) {
@@ -242,12 +290,9 @@ public class CashInterCashierApiResource {
@SuppressWarnings("unchecked")
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> row : heldRows) {
for (final Map<String, Object> row : heldRows == null ? List.<Map<String, Object>>of() : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -46,8 +47,26 @@ public class CashReconciliationApiResource {
private final CashReconciliationDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* A cashier reconciles only their own drawer. The cashier is picked on the screen, and two cashiers
* under one teller could name each other: one cashier's count then stands in for another's, and the
* reconciliation record is what a settlement is later allowed on the strength of. A user who is not
* a cashier-only login (a manager, the branch head) is not limited.
*/
private void requireOwnCashier(final Long cashierId) {
if (!cashierRoleHelper.isCashierOnly()) {
return;
}
final Long own = cashierRoleHelper.getCurrentCashierId();
if (own == null || !own.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.other.cashier",
"A cashier can only reconcile their own cash.");
}
}
/**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier.
@@ -64,6 +83,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
@@ -227,6 +247,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
requireOwnCashier(cashierId);
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
@@ -250,6 +271,14 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId is required.");
}
// A reconciliation is the cashier's own count of their own drawer. Unlike reading one (a manager
// looks at a cashier's, and day close checks each), recording one is the cashier's alone: neither
// another cashier nor a manager can count a drawer on its owner's behalf.
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
if (ownCashierId == null || !ownCashierId.equals(cashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.own.cashier.only",
"A reconciliation can only be recorded by the cashier whose drawer it is.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
@@ -338,7 +367,11 @@ public class CashReconciliationApiResource {
@GET
public String getAll() {
// A cashier sees their own reconciliations only; everyone else sees all of them.
final boolean cashierOnly = cashierRoleHelper.isCashierOnly();
final Long ownCashierId = cashierOnly ? cashierRoleHelper.getCurrentCashierId() : null;
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.filter(e -> !cashierOnly || (ownCashierId != null && ownCashierId.equals(e.getCashierId())))
.map(this::toMap).collect(Collectors.toList()));
}
@@ -347,6 +380,7 @@ public class CashReconciliationApiResource {
public String getOne(@PathParam("id") Long id) {
CashReconciliation header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
requireOwnCashier(header.getCashierId());
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
@@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
@@ -1083,6 +1084,137 @@ public class CashReportApiResource {
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
}
/**
* GET /cashreports/branch-position?officeId=X&asOfDate=yyyy-MM-dd
*
* The notes and coins a branch holds, denomination by denomination: its vault(s) plus every cashier's
* drawer. Cash moving between the vault and a drawer, or from one drawer to another, nets out in this
* total; what changes it is what crosses the counter -- a deposit, a withdrawal, an exchange. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) is +10 on ₹500 and -50 on ₹100 here, and the
* "exchangeNet" column carries that day's exchange movement beside the total so it can be seen.
*
* Until now no screen showed this total: the vault is untouched by an exchange and each drawer is shown
* alone, so what the branch held per denomination was only known from the hand-entered count at day
* open and close. Not for a cashier -- it spans every drawer; they see their own on Cashier Position.
*/
@GET
@Path("branch-position")
public String branchPosition(@QueryParam("officeId") final Long officeIdParam, @QueryParam("asOfDate") final String asOfDate) {
if (cashierRoleHelper.isCashierOnly()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.not.for.cashier",
"The branch cash position covers every drawer; a cashier sees their own on the Cashier Position screen.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
: DateUtils.getBusinessLocalDate().toString();
final Long officeId = officeIdParam != null ? officeIdParam : context.authenticatedUser().getOffice().getId();
final List<String> officeNames = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
if (officeNames.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.office.not.found",
"Office not found: " + officeId);
}
// One row per denomination in the master; a discontinued one (the ₹2000 note) shows only while
// something is still held or moved in it.
final Map<Long, Map<String, Object>> rows = new LinkedHashMap<>();
for (final Map<String, Object> m : jdbcTemplate
.queryForList("SELECT id, denomination, kind, is_active FROM m_denomination_master WHERE deleted = false")) {
final long id = ((Number) m.get("id")).longValue();
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", id);
row.put("denominationValue", ((Number) m.get("denomination")).intValue());
row.put("kind", m.get("kind"));
row.put("active", Boolean.TRUE.equals(m.get("is_active")));
row.put("vaultCount", 0L);
row.put("cashierCount", 0L);
row.put("exchangeNet", 0L);
rows.put(id, row);
}
boolean vaultHasBreakdown = false;
for (final Long tellerId : jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ? ORDER BY id", Long.class,
officeId)) {
final Map<String, Object> vault = vaultPositionService.buildPositionForVault(tellerId, dateFilter);
vaultHasBreakdown = vaultHasBreakdown || Boolean.TRUE.equals(vault.get("hasBreakdown"));
addPositionCounts(rows, vault, "vaultCount");
}
for (final Long cashierId : jdbcTemplate.queryForList(
"SELECT mc.id FROM m_cashiers mc JOIN m_tellers t ON t.id = mc.teller_id WHERE t.office_id = ? ORDER BY mc.id",
Long.class, officeId)) {
addPositionCounts(rows, buildPositionForCashier(cashierId, dateFilter), "cashierCount");
}
// The day's exchanges across the branch's cashiers: notes taken in less notes given out, per denomination.
for (final Map<String, Object> r : jdbcTemplate.queryForList("""
SELECT ced.denomination_id,
SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE -ced.count END) AS net
FROM m_cash_exchange_detail ced
JOIN m_cash_exchange ce ON ce.id = ced.exchange_id
JOIN m_cashiers mc ON mc.id = ce.cashier_id
JOIN m_tellers t ON t.id = mc.teller_id
WHERE t.office_id = ? AND ce.exchange_date = CAST(? AS DATE)
AND (ce.is_reversed IS NULL OR ce.is_reversed = false)
GROUP BY ced.denomination_id
""", officeId, dateFilter)) {
final Object id = r.get("denomination_id");
final Object net = r.get("net");
if (id instanceof Number && net instanceof Number && rows.containsKey(((Number) id).longValue())) {
rows.get(((Number) id).longValue()).put("exchangeNet", ((Number) net).longValue());
}
}
final List<Map<String, Object>> lines = new ArrayList<>();
BigDecimal vaultAmount = BigDecimal.ZERO;
BigDecimal cashiersAmount = BigDecimal.ZERO;
for (final Map<String, Object> row : rows.values()) {
final long vaultCount = ((Number) row.get("vaultCount")).longValue();
final long cashierCount = ((Number) row.get("cashierCount")).longValue();
final long exchangeNet = ((Number) row.get("exchangeNet")).longValue();
final int value = ((Number) row.get("denominationValue")).intValue();
if (!Boolean.TRUE.equals(row.get("active")) && vaultCount == 0 && cashierCount == 0 && exchangeNet == 0) {
continue;
}
row.put("totalCount", vaultCount + cashierCount);
row.put("totalAmount", BigDecimal.valueOf(value * (vaultCount + cashierCount)));
vaultAmount = vaultAmount.add(BigDecimal.valueOf(value * vaultCount));
cashiersAmount = cashiersAmount.add(BigDecimal.valueOf(value * cashierCount));
lines.add(row);
}
lines.sort((a, b) -> Integer.compare(((Number) b.get("denominationValue")).intValue(),
((Number) a.get("denominationValue")).intValue()));
final Map<String, Object> resp = new LinkedHashMap<>();
resp.put("officeId", officeId);
resp.put("officeName", officeNames.get(0));
resp.put("asOfDate", dateFilter);
resp.put("vaultHasBreakdown", vaultHasBreakdown);
resp.put("vaultAmount", vaultAmount);
resp.put("cashiersAmount", cashiersAmount);
resp.put("totalAmount", vaultAmount.add(cashiersAmount));
resp.put("denominations", lines);
return gson.toJson(resp);
}
/** Adds a position's per-denomination counts (a vault's or a cashier's) into one field of the rows. */
@SuppressWarnings("unchecked")
private void addPositionCounts(final Map<Long, Map<String, Object>> rows, final Map<String, Object> position,
final String field) {
final List<Map<String, Object>> lines = (List<Map<String, Object>>) position.get("denominations");
if (lines == null) {
return;
}
for (final Map<String, Object> line : lines) {
final Object id = line.get("denominationId");
final Object count = line.get("count");
if (!(id instanceof Number) || !(count instanceof Number)) {
continue;
}
final Map<String, Object> row = rows.get(((Number) id).longValue());
if (row != null) {
row.merge(field, ((Number) count).longValue(), (a, b) -> ((Number) a).longValue() + ((Number) b).longValue());
}
}
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
@@ -33,7 +33,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
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.springframework.stereotype.Component;
@@ -181,64 +180,6 @@ public class CashSettlementApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
CashSettlement settlement = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
"Settlement not found: " + id));
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
"Settlement " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
try {
Long glTellerId = settlement.getToTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.allocateCashToCashier(glTellerId,
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
@Path("check")
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
@@ -35,7 +35,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -160,65 +159,6 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
CashTransfer transfer = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
"Cash transfer not found: " + id));
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
"Cash transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {
// intentionally ignored
}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {
// intentionally ignored
}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
transfer.setReverseReason(reason);
repository.save(transfer);
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
try {
Long glTellerId = transfer.getFromTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.settleCashFromCashier(glTellerId,
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -176,6 +176,7 @@ public class ClerkDisbursementRequestApiResource {
// A GL leg carries no savingsAccountId and the guard ignores it.
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
// m_current_account -- checking it as a savings id would test some unrelated savings account.
rejectRecurringDepositLeg(entry, entryAccountType);
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
: KycRestrictionGuard.Operation.CREDIT;
@@ -486,6 +487,8 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(400, "Account ID missing for entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
}
// Also refuses requests raised before RD legs were blocked at submit.
rejectRecurringDepositLeg(entry, entryType);
}
}
@@ -932,6 +935,7 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
drAccountIds.add(e.get("savingsAccountId").getAsLong());
}
for (final JsonObject e : crEntries) {
@@ -944,6 +948,7 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
final Long crId = e.get("savingsAccountId").getAsLong();
if (drAccountIds.contains(crId)) throw apiError(400,
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
@@ -1193,6 +1198,31 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null);
}
/** m_savings_account.deposit_type_enum of a recurring deposit (Fineract's DepositAccountType.RECURRING_DEPOSIT). */
private static final int DEPOSIT_TYPE_RECURRING = 300;
/**
* A recurring deposit cannot be a leg of any clerk request. Every request type here posts straight into the
* account with a direct insert, which never reaches the RD's installment schedule: the installments stay unpaid,
* Auto Debit collects them a second time and the maturity amount is computed on the wrong schedule. RD
* installments come in through the cashier's Receipt (the RD deposit command) or the RD's own Auto Debit.
* Looked up by id, not trusted from the entry's label; a CURRENT leg's id belongs to m_current_account.
*/
private void rejectRecurringDepositLeg(final JsonObject entry, final String accountType) {
if ("GL".equals(accountType) || "CURRENT".equals(accountType) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_no FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
entry.get("savingsAccountId").getAsLong(), DEPOSIT_TYPE_RECURRING);
if (!rows.isEmpty()) {
throw apiError(422, "Recurring deposit " + rows.get(0).get("account_no")
+ " cannot be used in a clerk Disbursement, Collection, Fund Transfer or Bank Transfer."
+ " Pay RD installments through a cashier Receipt or Auto Debit.");
}
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -38,14 +38,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
private static final Set<String> VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT");
// A customer's PAN lives on their KYC record (m_client_kyc, one row per client) and, for some, as a
// "PAN Card" ID proof. The KYC record is what the client screens and cash screens read, so it wins;
// the ID proof is the fallback. Reading only the ID proof showed "PAN -" for customers whose PAN was
// on file, and a search by PAN number found none of them.
private static final String BASE_SELECT = """
SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name,
acct.account_no, acct.account_type,
pan.document_key AS pan_number, aad.document_key AS aadhaar_number,
COALESCE(NULLIF(kyc.pan_number, ''), pan.document_key) AS pan_number, aad.document_key AS aadhaar_number,
addr.address_line_1, addr.address_line_2, addr.town_village, addr.city,
addr.county_district, addr.postal_code
FROM m_client c
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc kyc ON kyc.client_id = c.id
LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = (
SELECT id FROM m_code_value WHERE code_value = 'PAN Card'
AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType'))
@@ -100,7 +105,8 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
params.add(aadhaarNumber.trim());
}
if (hasPan) {
sql.append(" AND UPPER(pan.document_key) = UPPER(?) ");
sql.append(" AND (UPPER(kyc.pan_number) = UPPER(?) OR UPPER(pan.document_key) = UPPER(?)) ");
params.add(panNumber.trim());
params.add(panNumber.trim());
}
if (hasAddress) {
@@ -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);
}
}

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