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:
+6
@@ -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();
|
||||
|
||||
+28
-3
@@ -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 {} = {}]",
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+4
@@ -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";
|
||||
}
|
||||
|
||||
+7
-1
@@ -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());
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+6
@@ -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());
|
||||
|
||||
+90
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/agents")
|
||||
@Component
|
||||
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentsApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final AgentReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
|
||||
public Collection<AgentData> retrieveAll() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{agentId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
|
||||
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveOne(agentId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
|
||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate\n" + "\n"
|
||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, officeId, guarantors\n" + "\n"
|
||||
+ "officeId is the branch the agent belongs to, and defaults to the client's own office. "
|
||||
+ "agentSavingAccountNo must be an open savings account of that same client - the agent's "
|
||||
+ "commission is paid into it.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createAgent() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{agentId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
|
||||
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateAgent(agentId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{agentId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close a DDS Agent", description = "Ends the relationship with an agent. Refused while they still have "
|
||||
+ "accounts assigned, hold collected cash, or are owed commission - the response names which. Distinct from "
|
||||
+ "deactivating the agent, which stops them collecting and is never refused.")
|
||||
public CommandProcessingResult close(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.closeAgent(agentId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AgentRepository extends JpaRepository<Agent, Long> {
|
||||
|
||||
/**
|
||||
* Native rather than JPQL: {@code cast(... as long)} reaches PostgreSQL verbatim, which knows no
|
||||
* "long" type - the cast has to name a database type.
|
||||
*/
|
||||
@Query(value = "select coalesce(max(cast(substring(agent_code, 3) as bigint)), 0) from m_dds_agent "
|
||||
+ "where agent_code like 'AG%'", nativeQuery = true)
|
||||
Long retrieveMaxAgentCodeSuffix();
|
||||
|
||||
Optional<Agent> findByAgentCode(String agentCode);
|
||||
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
|
||||
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* Agents with commission earned but not yet paid - what a Commission Posting run has to pay out.
|
||||
* Agents owed nothing are left out rather than previewed as zero rows.
|
||||
*/
|
||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo and a.commission > 0")
|
||||
List<Agent> findWithCommissionOwed(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
||||
|
||||
/**
|
||||
* The agent already holding this ledger account, if any.
|
||||
*
|
||||
* Asked before an account is handed to a second agent. The database refuses it too, but a
|
||||
* constraint violation names a constraint; this names the agent, which is what the person
|
||||
* choosing needs to hear.
|
||||
*/
|
||||
@Query("select a from Agent a where a.glAccount.id = :glAccountId")
|
||||
Agent findByGlAccountId(@Param("glAccountId") Long glAccountId);
|
||||
|
||||
/**
|
||||
* The client's agents still on the books. One person is one agent: a second would split their
|
||||
* collections and commission between two codes paying into the same account.
|
||||
*/
|
||||
@Query("select a from Agent a where a.client.id = :clientId and a.active = true")
|
||||
List<Agent> findActiveByClientId(@Param("clientId") Long clientId);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "AGENT", action = "CLOSE")
|
||||
public class CloseAgentCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final AgentWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CloseAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.closeAgent(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "AGENT", action = "CREATE")
|
||||
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final AgentWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createAgent(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "AGENT", action = "UPDATE")
|
||||
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final AgentWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateAgent(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotGuaranteeItselfException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Validates create/update commands for an Agent - the DDS collection agent. The parameter set follows
|
||||
* the legacy Agent Master Entry screen. homeBranch is deliberately absent: it is derived from the
|
||||
* agent's client's office in the write service, not supplied by the caller.
|
||||
*/
|
||||
@Component
|
||||
public final class AgentCommandFromApiJsonDeserializer {
|
||||
|
||||
public static final String CLIENT_ID = "clientId";
|
||||
public static final String AGENT_NAME_ID = "agentNameId";
|
||||
public static final String AGENT_ADDRESS1 = "agentAddress1";
|
||||
public static final String AGENT_ADDRESS2 = "agentAddress2";
|
||||
public static final String AGENT_CITY = "agentCity";
|
||||
public static final String AGENT_PINCODE = "agentPinCode";
|
||||
public static final String AGENT_TELEPHONE = "agentTelephone";
|
||||
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
|
||||
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
|
||||
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
|
||||
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
|
||||
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
|
||||
|
||||
/**
|
||||
* The ages the bank takes agents on at: at least 16, and not yet 60. Kept as named constants
|
||||
* rather than written into the check, because they are policy numbers the bank may one day want
|
||||
* to read off, not an arithmetic detail.
|
||||
*/
|
||||
private static final int MINIMUM_AGENT_AGE_YEARS = 16;
|
||||
private static final int MAXIMUM_AGENT_AGE_YEARS = 60;
|
||||
|
||||
/**
|
||||
* Which branch the agent works out of, as an office id. Optional: it defaults to the client's
|
||||
* own office, and is only sent when the agent collects out of a different branch from the one
|
||||
* their client record sits in. The DDS batch runs filter by it.
|
||||
*
|
||||
* The branch name is deliberately not accepted - it is derived from the office the id names, so
|
||||
* a caller cannot file an agent under a branch that does not exist, or under a name that
|
||||
* disagrees with the office actually stored.
|
||||
*/
|
||||
public static final String OFFICE_ID = "officeId";
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through. Required, and one agent to an
|
||||
* account: what is left standing on it is the cash this agent is holding and has not brought in,
|
||||
* which is a question nobody could answer while every agent shared one account.
|
||||
*
|
||||
* The agents created before this existed carry none, and go on posting to the shared account
|
||||
* until somebody opens them and chooses - the rule is enforced on what is written from here on,
|
||||
* not applied backwards over records nobody has looked at.
|
||||
*/
|
||||
public static final String GL_ACCOUNT_ID = "glAccountId";
|
||||
|
||||
public static final String ACTIVE = "active";
|
||||
public static final String GUARANTORS = "guarantors";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
public static final String LOCALE = "locale";
|
||||
|
||||
/**
|
||||
* Commission settings and the standing deductions the commission run applies. Update-only: an
|
||||
* agent is created earning full commission with nothing deducted, and these are set afterwards
|
||||
* when the bank decides otherwise. The running totals the batch maintains - the commission owed
|
||||
* and the TDS withheld - are not parameters at all; they are outcomes of posted runs.
|
||||
*/
|
||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_PERCENT = "tdsPercent";
|
||||
public static final String DEDUCT_COMMISSION = "deductCommission";
|
||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
||||
|
||||
private static final String GUARANTOR_CLIENT_ID = "clientId";
|
||||
private static final String GUARANTOR_NAME = "guarantorName";
|
||||
private static final String GUARANTOR_ADDRESS1 = "address1";
|
||||
private static final String GUARANTOR_ADDRESS2 = "address2";
|
||||
private static final String GUARANTOR_CITY = "city";
|
||||
private static final String GUARANTOR_PINCODE = "pinCode";
|
||||
private static final String GUARANTOR_TELEPHONE = "telephone";
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
|
||||
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
|
||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, OFFICE_ID, GL_ACCOUNT_ID, ACTIVE, GUARANTORS, DATE_FORMAT,
|
||||
LOCALE));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(SUPPORTED_PARAMETERS);
|
||||
static {
|
||||
SUPPORTED_UPDATE_PARAMETERS.addAll(Arrays.asList(COMMISSION_CALCULATION, TDS_APPLICABLE, TDS_PERCENT, DEDUCT_COMMISSION,
|
||||
AGENT_DEPOSIT, FESTIVAL_ADVANCE, OTHER_DEDUCTION));
|
||||
}
|
||||
|
||||
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
|
||||
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
|
||||
|
||||
/*
|
||||
* What a phone number and a pincode have to look like.
|
||||
*
|
||||
* Length alone was all these carried, so either could be typed as letters and nothing would say
|
||||
* so until somebody tried to ring the agent or post to them. Kept in step with the Party screen,
|
||||
* which asks for the same two things of the customer.
|
||||
*/
|
||||
private static final Pattern TELEPHONE_PATTERN = Pattern.compile("^[0-9+()\\-\\s]{6,20}$");
|
||||
|
||||
private static final Pattern PINCODE_PATTERN = Pattern.compile("^[1-9][0-9]{5}$");
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
||||
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
||||
|
||||
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
||||
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
||||
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
||||
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
||||
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
||||
}
|
||||
|
||||
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
||||
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
||||
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
||||
.notExceedingLengthOf(100);
|
||||
|
||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
||||
|
||||
// Optional: the write service falls back to the client's own office. Only checked for shape
|
||||
// here - that the office exists is the write service's business.
|
||||
if (this.fromApiJsonHelper.parameterExists(OFFICE_ID, element)) {
|
||||
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
|
||||
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).ignoreIfNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
// Required, because an agent without one of their own falls back to the account every other
|
||||
// agent shares - and a balance sitting on that account says money is outstanding without
|
||||
// saying whose, which is the whole thing this field exists to stop.
|
||||
//
|
||||
// Shape only. Whether the account exists, may carry entries, and is not already another
|
||||
// agent's is the write service's business - it has the ledger and the other agents to hand.
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
||||
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator, true);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
public void validateForUpdate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
|
||||
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
||||
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
|
||||
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
||||
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
|
||||
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
||||
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
||||
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
||||
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
|
||||
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
||||
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
|
||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_PINCODE, agentPinCode, PINCODE_PATTERN,
|
||||
"Pincode must be 6 digits and cannot start with 0");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
||||
mustLookLike(baseDataValidator, AGENT_TELEPHONE, agentTelephone, TELEPHONE_PATTERN,
|
||||
"Telephone number may contain only digits, spaces and + - ( )");
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
|
||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
||||
.notExceedingLengthOf(100);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
|
||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
|
||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
|
||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
||||
validateAgentBirthDate(agentBirthDate, baseDataValidator, false);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
// Sent means chosen: an update may leave the field out and keep what the agent has, but it
|
||||
// may not send it empty. Clearing it would put the agent back on the shared account, which
|
||||
// is the state this field was added to get out of.
|
||||
if (this.fromApiJsonHelper.parameterExists(GL_ACCOUNT_ID, element)) {
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(GL_ACCOUNT_ID, element);
|
||||
baseDataValidator.reset().parameter(GL_ACCOUNT_ID).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
// Only ever sent on its own, by the list's activate/deactivate action - an agent is always
|
||||
// created active, so this is an update-time parameter.
|
||||
if (this.fromApiJsonHelper.parameterExists(ACTIVE, element)) {
|
||||
final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ACTIVE, element);
|
||||
baseDataValidator.reset().parameter(ACTIVE).value(active).notNull();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
|
||||
final Boolean commissionCalculation = this.fromApiJsonHelper.extractBooleanNamed(COMMISSION_CALCULATION, element);
|
||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION).value(commissionCalculation).notNull();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
||||
final Boolean tdsApplicable = this.fromApiJsonHelper.extractBooleanNamed(TDS_APPLICABLE, element);
|
||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).value(tdsApplicable).notNull();
|
||||
}
|
||||
|
||||
// A tax rate above 100% would take more than the commission it is levied on.
|
||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
||||
final var tdsPercent = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element);
|
||||
baseDataValidator.reset().parameter(TDS_PERCENT).value(tdsPercent).ignoreIfNull().zeroOrPositiveAmount()
|
||||
.notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
}
|
||||
|
||||
validateDeduction(baseDataValidator, element, DEDUCT_COMMISSION);
|
||||
validateDeduction(baseDataValidator, element, AGENT_DEPOSIT);
|
||||
validateDeduction(baseDataValidator, element, FESTIVAL_ADVANCE);
|
||||
validateDeduction(baseDataValidator, element, OTHER_DEDUCTION);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deduction is an amount withheld, so it is entered as a positive figure - a negative one would
|
||||
* quietly pay the agent more rather than less.
|
||||
*/
|
||||
private void validateDeduction(final DataValidatorBuilder baseDataValidator, final JsonElement element, final String parameterName) {
|
||||
if (this.fromApiJsonHelper.parameterExists(parameterName, element)) {
|
||||
final var value = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(parameterName, element);
|
||||
baseDataValidator.reset().parameter(parameterName).value(value).ignoreIfNull().zeroOrPositiveAmount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A date of birth that is today or later describes nobody, and until now nothing stopped one
|
||||
* being saved - the field was only checked for being present. The slip is easy to make, because
|
||||
* the date picker opens on today and a stray Enter takes it.
|
||||
*
|
||||
* The age is measured against today rather than against the opening date: an agent opened years
|
||||
* ago has only grown older since, so today is the reading that cannot wrongly reject a record
|
||||
* that was legitimate when it was made.
|
||||
*
|
||||
* The age range is applied when an agent is taken on. An agent already on the books is not
|
||||
* refused an edit for having turned 60 since - their age was right when they were appointed.
|
||||
*/
|
||||
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator,
|
||||
final boolean appointing) {
|
||||
if (agentBirthDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!agentBirthDate.isBefore(today)) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCode("cannot.be.today.or.a.future.date");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appointing) {
|
||||
return;
|
||||
}
|
||||
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCodeAndMessage("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old",
|
||||
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
|
||||
} else if (!agentBirthDate.isAfter(today.minusYears(MAXIMUM_AGENT_AGE_YEARS))) {
|
||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
|
||||
.failWithCodeAndMessage("agent.must.be.under." + MAXIMUM_AGENT_AGE_YEARS + ".years.old",
|
||||
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
||||
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
|
||||
|
||||
final Long agentClientId = extractLong(topLevelJson, CLIENT_ID);
|
||||
|
||||
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
|
||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
|
||||
|
||||
final Long guarantorClientId = extractLong(guarantorJson, GUARANTOR_CLIENT_ID);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i).value(guarantorClientId).notNull()
|
||||
.integerGreaterThanZero();
|
||||
|
||||
// An agent cannot stand guarantor for itself - a guarantee only counts coming from
|
||||
// someone else.
|
||||
if (agentClientId != null && agentClientId.equals(guarantorClientId)) {
|
||||
throw new AgentCannotGuaranteeItselfException(agentClientId);
|
||||
}
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
|
||||
.notBlank().notExceedingLengthOf(100);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
|
||||
.notBlank().notExceedingLengthOf(200);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
|
||||
.notBlank().notExceedingLengthOf(100);
|
||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
|
||||
.notBlank().notExceedingLengthOf(20);
|
||||
}
|
||||
}
|
||||
|
||||
private String extractString(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long extractLong(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
|
||||
}
|
||||
|
||||
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
||||
}
|
||||
|
||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a value against the shape its kind has to take, and says what that shape is.
|
||||
*
|
||||
* The message names the format rather than only rejecting the entry: "must be 6 digits" tells
|
||||
* the clerk what to type next, where "invalid" leaves them guessing.
|
||||
*/
|
||||
private void mustLookLike(final DataValidatorBuilder v, final String paramName, final String value, final Pattern shape,
|
||||
final String describedAs) {
|
||||
if (org.apache.commons.lang3.StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
// The message is given to the validator rather than left in the args, so what the clerk
|
||||
// reads is what to type next rather than that something was wrong.
|
||||
v.reset().parameter(paramName).value(value).matchesRegularExpression(shape.pattern(), describedAs);
|
||||
}
|
||||
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||
|
||||
public interface AgentReadPlatformService {
|
||||
|
||||
Collection<AgentData> retrieveAll();
|
||||
|
||||
AgentData retrieveOne(Long agentId);
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
|
||||
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
|
||||
|
||||
private static final class AgentMapper implements RowMapper<AgentData> {
|
||||
|
||||
public String schema() {
|
||||
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
|
||||
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
|
||||
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
|
||||
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
|
||||
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
|
||||
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
|
||||
// The branch name is read off the office itself, so renaming a branch shows
|
||||
// through here rather than leaving every agent under the old name.
|
||||
+ " a.office_id as officeId, coalesce(o.name, a.home_branch) as homeBranch, "
|
||||
// The ledger account this agent's collections pass through, with its code and name so
|
||||
// the screen can show what was chosen without a second call.
|
||||
+ " a.gl_account_id as glAccountId, gl.gl_code as glAccountCode, gl.name as glAccountName, "
|
||||
+ " a.commission as commission, a.tds_amount as tdsAmount, "
|
||||
+ " a.commission_calculation as commissionCalculation, a.tds_applicable as tdsApplicable, "
|
||||
+ " a.tds_percent as tdsPercent, a.deduct_commission as deductCommission, a.agent_deposit as agentDeposit, "
|
||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction, "
|
||||
+ " a.is_active as active, a.close_date as closeDate, "
|
||||
+ " a.created_date as createdDate, a.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id"
|
||||
+ " left join m_office o on o.id = a.office_id"
|
||||
+ " left join acc_gl_account gl on gl.id = a.gl_account_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return AgentData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.clientId(rs.getLong("clientId")) //
|
||||
.clientName(rs.getString("clientName")) //
|
||||
.agentCode(rs.getString("agentCode")) //
|
||||
.agentNameId(rs.getString("agentNameId")) //
|
||||
.agentAddress1(rs.getString("agentAddress1")) //
|
||||
.agentAddress2(rs.getString("agentAddress2")) //
|
||||
.agentCity(rs.getString("agentCity")) //
|
||||
.agentPinCode(rs.getString("agentPinCode")) //
|
||||
.agentTelephone(rs.getString("agentTelephone")) //
|
||||
.agentSavingAccountType(rs.getString("agentSavingAccountType")) //
|
||||
.agentSavingAccountNo(rs.getString("agentSavingAccountNo")) //
|
||||
.agentOpeningDate(JdbcSupport.getLocalDate(rs, "agentOpeningDate")) //
|
||||
.agentBirthDate(JdbcSupport.getLocalDate(rs, "agentBirthDate")) //
|
||||
.agentCurrentBalance(rs.getBigDecimal("agentCurrentBalance")) //
|
||||
.agentOpeningBalance(rs.getBigDecimal("agentOpeningBalance")) //
|
||||
.officeId(JdbcSupport.getLong(rs, "officeId")) //
|
||||
.glAccountId(JdbcSupport.getLong(rs, "glAccountId")) //
|
||||
.glAccountCode(rs.getString("glAccountCode")) //
|
||||
.glAccountName(rs.getString("glAccountName")) //
|
||||
.homeBranch(rs.getString("homeBranch")) //
|
||||
.commission(rs.getBigDecimal("commission")) //
|
||||
.tdsAmount(rs.getBigDecimal("tdsAmount")) //
|
||||
.commissionCalculation(rs.getBoolean("commissionCalculation")) //
|
||||
.tdsApplicable(rs.getBoolean("tdsApplicable")) //
|
||||
.tdsPercent(rs.getBigDecimal("tdsPercent")) //
|
||||
.deductCommission(rs.getBigDecimal("deductCommission")) //
|
||||
.agentDeposit(rs.getBigDecimal("agentDeposit")) //
|
||||
.festivalAdvance(rs.getBigDecimal("festivalAdvance")) //
|
||||
.otherDeduction(rs.getBigDecimal("otherDeduction")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.guarantors(new ArrayList<>()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
|
||||
|
||||
public String schema() {
|
||||
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
|
||||
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
|
||||
+ " g.telephone as telephone from m_dds_agent_guarantor g";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
final Long agentId = rs.getLong("agentId");
|
||||
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
|
||||
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
|
||||
rs.getString("pinCode"), rs.getString("telephone"));
|
||||
return Map.entry(agentId, data);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<AgentData> retrieveAll() {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
|
||||
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
|
||||
return attachGuarantors(agents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentData retrieveOne(final Long agentId) {
|
||||
try {
|
||||
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
|
||||
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
|
||||
return attachGuarantors(List.of(agent)).iterator().next();
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new AgentNotFoundException(agentId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<AgentData> attachGuarantors(final List<AgentData> agents) {
|
||||
if (agents.isEmpty()) {
|
||||
return agents;
|
||||
}
|
||||
|
||||
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
|
||||
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
|
||||
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
|
||||
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
|
||||
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
||||
}
|
||||
|
||||
final List<AgentData> result = new ArrayList<>(agents.size());
|
||||
for (final AgentData agent : agents) {
|
||||
result.add(agent.toBuilder().guarantors(guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>())).build());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface AgentWritePlatformService {
|
||||
|
||||
CommandProcessingResult createAgent(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
|
||||
* collected cash, or are owed commission - each of those would be left with nobody responsible
|
||||
* for it. Distinct from deactivating, which stops an agent collecting and is never blocked.
|
||||
*/
|
||||
CommandProcessingResult closeAgent(Long agentId, JsonCommand command);
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountClientMismatchException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotFoundException;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentSavingsAccountNotOperativeException;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentGlAccountUnusableException;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
|
||||
|
||||
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final AgentRepository agentRepository;
|
||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createAgent(final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final Long clientId = command.longValueOfParameterNamed("clientId");
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
|
||||
refuseIfAlreadyAnAgent(clientId, null);
|
||||
validateSavingsAccount(clientId, command);
|
||||
|
||||
final String agentCode = generateNextAgentCode();
|
||||
final Agent agent = Agent.fromJson(client, agentCode, officeOf(client, command), command);
|
||||
agent.setGlAccount(glAccountOf(command, null));
|
||||
|
||||
addGuarantorsFromCommand(agent, command);
|
||||
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(agent.getId()) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleAgentDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
// Checked against whichever client the agent will belong to after this update, not the
|
||||
// one they belonged to before - otherwise moving an agent to a new client would carry the
|
||||
// old client's account across unchallenged.
|
||||
final Long owningClientId = command.parameterExists("clientId") ? command.longValueOfParameterNamed("clientId")
|
||||
: agentForUpdate.clientId();
|
||||
validateSavingsAccount(owningClientId, command);
|
||||
|
||||
final Map<String, Object> changesOnly = agentForUpdate.update(command);
|
||||
|
||||
if (changesOnly.containsKey("clientId")) {
|
||||
final Long newClientId = (Long) changesOnly.get("clientId");
|
||||
refuseIfAlreadyAnAgent(newClientId, agentId);
|
||||
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
|
||||
agentForUpdate.changeClient(newClient);
|
||||
// The branch follows the client unless the caller named one - see officeOf.
|
||||
agentForUpdate.setOffice(officeOf(newClient, command));
|
||||
changesOnly.put("officeId", agentForUpdate.officeId());
|
||||
} else if (command.isChangeInLongParameterNamed("officeId", agentForUpdate.officeId())) {
|
||||
final Long newOfficeId = command.longValueOfParameterNamed("officeId");
|
||||
agentForUpdate.setOffice(this.officeRepositoryWrapper.findOneWithNotFoundDetection(newOfficeId));
|
||||
changesOnly.put("officeId", newOfficeId);
|
||||
}
|
||||
|
||||
if (command.isChangeInLongParameterNamed("glAccountId", agentForUpdate.glAccountId())) {
|
||||
agentForUpdate.setGlAccount(glAccountOf(command, agentForUpdate.getId()));
|
||||
changesOnly.put("glAccountId", agentForUpdate.glAccountId());
|
||||
}
|
||||
|
||||
if (command.parameterExists("guarantors")) {
|
||||
agentForUpdate.resetGuarantors();
|
||||
addGuarantorsFromCommand(agentForUpdate, command);
|
||||
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
|
||||
}
|
||||
|
||||
this.agentRepository.saveAndFlush(agentForUpdate);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleAgentDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the relationship with an agent, refusing while anything is still outstanding.
|
||||
*
|
||||
* The three conditions are the three things that would be left with nobody responsible for them:
|
||||
* accounts still being collected from, cash the agent holds that has not reached a branch, and
|
||||
* commission earned but not paid. Each is reported by name and figure, so whoever is closing the
|
||||
* agent knows exactly what to settle first rather than being told only that it failed.
|
||||
*
|
||||
* Deactivating an agent is deliberately not guarded this way - see
|
||||
* {@link org.apache.fineract.organisation.agent.exception.AgentCannotBeClosedException}.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult closeAgent(final Long agentId, final JsonCommand command) {
|
||||
|
||||
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
||||
|
||||
if (agent.isClosed()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "it is already closed");
|
||||
}
|
||||
|
||||
final Long openAccounts = this.partyRepository.countActivePartiesOfAgent(agentId);
|
||||
if (openAccounts != null && openAccounts > 0) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), openAccounts
|
||||
+ (openAccounts == 1 ? " account is" : " accounts are")
|
||||
+ " still assigned to them. Move them to another agent first.");
|
||||
}
|
||||
|
||||
if (agent.holdsCash()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(), "they still hold "
|
||||
+ agent.getAgentCurrentBalance().toPlainString()
|
||||
+ " in collected cash. Settle it to a branch first (Agent Tran).");
|
||||
}
|
||||
|
||||
if (agent.isOwedCommission()) {
|
||||
throw new AgentCannotBeClosedException(agent.getAgentCode(),
|
||||
"they are still owed " + agent.getCommission().toPlainString()
|
||||
+ " in commission. Pay it out first (Commission Posting).");
|
||||
}
|
||||
|
||||
agent.close(DateUtils.getBusinessLocalDate());
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which branch this agent belongs to.
|
||||
*
|
||||
* The client's own office is the default, since that is where the person is on the bank's books.
|
||||
* A caller may still name a branch, because an agent can be registered at one office and collect
|
||||
* out of another - the batch runs filter by this, so it has to say where the agent actually
|
||||
* works rather than where their client record happens to sit.
|
||||
*
|
||||
* Resolved to an actual office row rather than taken as a name: an unknown id is refused here,
|
||||
* where a name that matched no branch used to be stored as happily as one that did.
|
||||
*/
|
||||
/**
|
||||
* The ledger account this agent's collections are to pass through, or null to leave them on the
|
||||
* scheme's shared one.
|
||||
*
|
||||
* Four things are asked of it, and each has a way of going wrong that would not show up until
|
||||
* money was moving. It has to exist. It has to be a detail account, because a header account is
|
||||
* a heading over others and carries no entries of its own. It has to be enabled, or every
|
||||
* posting through it would be refused. And it has to belong to no other agent, since two agents
|
||||
* on one account is the shared account again under a different name - the balance would say what
|
||||
* is outstanding without saying whose.
|
||||
*
|
||||
* What it deliberately does not check is the account's classification. Cash held by an agent is
|
||||
* an asset and the branch will almost always pick one, but where a bank chooses to carry it is
|
||||
* the bank's own accounting policy, and this is not the place to overrule it.
|
||||
*
|
||||
* @param forAgentId
|
||||
* the agent being changed, so an agent keeping its own account is not refused for
|
||||
* already holding it. Null when the agent does not exist yet.
|
||||
*/
|
||||
private GLAccount glAccountOf(final JsonCommand command, final Long forAgentId) {
|
||||
if (!command.parameterExists("glAccountId")) {
|
||||
return null;
|
||||
}
|
||||
final Long glAccountId = command.longValueOfParameterNamed("glAccountId");
|
||||
if (glAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final GLAccount glAccount = this.glAccountRepository.findById(glAccountId)
|
||||
.orElseThrow(() -> new AgentGlAccountUnusableException(String.valueOf(glAccountId), "no such ledger account exists"));
|
||||
|
||||
if (glAccount.isHeaderAccount()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is a header account and carries no entries of its own");
|
||||
}
|
||||
if (glAccount.isDisabled()) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "it is disabled");
|
||||
}
|
||||
|
||||
final Agent holder = this.agentRepository.findByGlAccountId(glAccountId);
|
||||
if (holder != null && !holder.getId().equals(forAgentId)) {
|
||||
throw new AgentGlAccountUnusableException(glAccount.getGlCode(), "agent " + holder.getAgentCode() + " already uses it");
|
||||
}
|
||||
|
||||
return glAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* One client is one agent. A second code for the same person splits their collections, their
|
||||
* commission and their cash-in-hand across two records paying into the same account, and every
|
||||
* agent-wise report then shows them twice. A closed agent does not count - the person can be taken
|
||||
* on again.
|
||||
*/
|
||||
private void refuseIfAlreadyAnAgent(final Long clientId, final Long forAgentId) {
|
||||
for (final Agent existing : this.agentRepository.findActiveByClientId(clientId)) {
|
||||
if (!existing.getId().equals(forAgentId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.agent.client.already.agent",
|
||||
"This client is already agent " + existing.getAgentCode() + ". One client can be only one agent.",
|
||||
"clientId", existing.getAgentCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Office officeOf(final Client client, final JsonCommand command) {
|
||||
if (command.parameterExists("officeId")) {
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
if (officeId != null) {
|
||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
}
|
||||
}
|
||||
return client.getOffice();
|
||||
}
|
||||
|
||||
/**
|
||||
* The open states of a savings account, matching SavingsAccountRepository's own "non closed" set.
|
||||
* deposit_type_enum comes back too: 100 is a savings account, 200 a fixed deposit, 300 recurring.
|
||||
*/
|
||||
/**
|
||||
* The account an agent's commission is to be paid into, and enough about it to say whether it
|
||||
* can take one.
|
||||
*
|
||||
* Active only. An account still awaiting approval, or approved but never activated, takes no
|
||||
* credit at all - and one accepted here would fail silently on every commission run from then
|
||||
* on, leaving the agent unpaid with nothing on screen to say why.
|
||||
*/
|
||||
private static final String OPEN_ACCOUNT_SQL = "select a.client_id, p.deposit_type_enum, a.product_id from m_savings_account a "
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.account_no = ? and a.status_enum = 300";
|
||||
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
/**
|
||||
* Refuses a savings account that does not exist, or that belongs to somebody else.
|
||||
*
|
||||
* The agent's commission is paid into this account. Until this ran the number was free text, and
|
||||
* the live data showed what that costs: agent AG0001 carried "1234567890", which matches no
|
||||
* account in the database at all - so every commission recorded against it had nowhere to go.
|
||||
*
|
||||
* Enforced here rather than only on the screen, because the API can be called directly.
|
||||
*
|
||||
* Reads the owner's id rather than loading the account entity. Loading it pulled its product in
|
||||
* too, and saving the agent afterwards then failed for a fixed-deposit account with "The
|
||||
* attribute [id] of class FixedDepositProduct ... Updates are not allowed" - the agent's own
|
||||
* insert flushing a savings product it had merely looked at. Two columns are all this check
|
||||
* needs, and reading them touches no entity the transaction can try to write back.
|
||||
*/
|
||||
private void validateSavingsAccount(final Long expectedClientId, final JsonCommand command) {
|
||||
if (!command.parameterExists("agentSavingAccountNo")) {
|
||||
return;
|
||||
}
|
||||
final String accountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(OPEN_ACCOUNT_SQL, accountNo); // NOSONAR
|
||||
if (accounts.isEmpty()) {
|
||||
throw new AgentSavingsAccountNotFoundException(accountNo);
|
||||
}
|
||||
|
||||
final Map<String, Object> account = accounts.get(0);
|
||||
final Long ownerId = ((Number) account.get("client_id")).longValue();
|
||||
if (!expectedClientId.equals(ownerId)) {
|
||||
throw new AgentSavingsAccountClientMismatchException(accountNo);
|
||||
}
|
||||
|
||||
// Commission is credited whenever a run posts, which only a savings account accepts.
|
||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||
if (depositType != SAVINGS_DEPOSIT_TYPE) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, depositType == 200 ? "fixed deposit" : "recurring deposit");
|
||||
}
|
||||
|
||||
// A DDS scheme account is a savings account too, so the check above lets it through - but it
|
||||
// belongs to a customer's scheme, and commission paid into it would be counted as that
|
||||
// customer's deposits by every DDS interest run from then on.
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
|
||||
}
|
||||
}
|
||||
|
||||
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
|
||||
if (!command.parameterExists("guarantors")) {
|
||||
return;
|
||||
}
|
||||
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
|
||||
if (guarantorsArray == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
||||
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
|
||||
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
|
||||
final String address1 = guarantorJson.get("address1").getAsString();
|
||||
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
|
||||
? guarantorJson.get("address2").getAsString()
|
||||
: null;
|
||||
final String city = guarantorJson.get("city").getAsString();
|
||||
final String pinCode = guarantorJson.get("pinCode").getAsString();
|
||||
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
|
||||
? guarantorJson.get("telephone").getAsString()
|
||||
: null;
|
||||
|
||||
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
|
||||
}
|
||||
}
|
||||
|
||||
private String generateNextAgentCode() {
|
||||
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
|
||||
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
||||
return String.format("AG%04d", nextSuffix);
|
||||
}
|
||||
|
||||
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
|
||||
}
|
||||
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.agent.starter;
|
||||
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class OrganisationAgentConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AgentReadPlatformService.class)
|
||||
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new AgentReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AgentWritePlatformService.class)
|
||||
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
|
||||
OfficeRepositoryWrapper officeRepositoryWrapper, GLAccountRepository glAccountRepository, PartyRepository partyRepository,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper,
|
||||
officeRepositoryWrapper, glAccountRepository, partyRepository, jdbcTemplate);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/commissionslabs")
|
||||
@Component
|
||||
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
|
||||
@RequiredArgsConstructor
|
||||
public class CommissionSlabsApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CommissionSlabReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
|
||||
public Collection<CommissionSlabData> retrieveAll() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
|
||||
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createCommissionSlabs() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{slabId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Correct a DDS Commission Slab", description = "Optional fields: slabType, slabFrom, slabTo, "
|
||||
+ "commissionPercent. The corrected range is re-checked against the other slabs of its type and refused if it "
|
||||
+ "overlaps one of them.")
|
||||
public CommandProcessingResult update(@PathParam("slabId") @Parameter(description = "slabId") final Long slabId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateCommissionSlab(slabId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
|
||||
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
|
||||
|
||||
@Query("select s from CommissionSlab s where s.slabType = :slabType order by s.slabFrom")
|
||||
List<CommissionSlab> findByType(@Param("slabType") String slabType);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "COMMISSIONSLAB", action = "CREATE")
|
||||
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CommissionSlabWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createCommissionSlabs(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "COMMISSIONSLAB", action = "UPDATE")
|
||||
public class UpdateCommissionSlabCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CommissionSlabWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateCommissionSlabCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateCommissionSlab(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
|
||||
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
|
||||
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
@Autowired
|
||||
public CommissionSlabCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
if (!this.fromApiJsonHelper.parameterExists("slabs", element)) {
|
||||
v.reset().parameter("slabs").value(null).notNull();
|
||||
} else {
|
||||
final JsonObject topLevel = element.getAsJsonObject();
|
||||
final var slabsArray = topLevel.getAsJsonArray("slabs");
|
||||
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
|
||||
|
||||
for (int i = 0; i < slabsArray.size(); i++) {
|
||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
|
||||
|
||||
final String slabType = extractString(slabJson, "slabType");
|
||||
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
}
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* A correction to one existing slab - a flat object, not the array the create endpoint takes,
|
||||
* because only one row is ever being fixed.
|
||||
*/
|
||||
public void validateForUpdate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_UPDATE_PARAMETERS);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
|
||||
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("slabType", element)) {
|
||||
v.reset().parameter("slabType").value(this.fromApiJsonHelper.extractStringNamed("slabType", element)).notBlank()
|
||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabFrom", element)) {
|
||||
v.reset().parameter("slabFrom").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabFrom", element))
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("slabTo", element)) {
|
||||
v.reset().parameter("slabTo").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("slabTo", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||
v.reset().parameter("commissionPercent")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private String extractString(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
||||
}
|
||||
|
||||
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
||||
}
|
||||
|
||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||
|
||||
public interface CommissionSlabReadPlatformService {
|
||||
|
||||
Collection<CommissionSlabData> retrieveAll();
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
|
||||
|
||||
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
|
||||
|
||||
public String schema() {
|
||||
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
|
||||
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return CommissionSlabData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.slabType(rs.getString("slabType")) //
|
||||
.slabFrom(rs.getBigDecimal("slabFrom")) //
|
||||
.slabTo(rs.getBigDecimal("slabTo")) //
|
||||
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommissionSlabData> retrieveAll() {
|
||||
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
|
||||
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface CommissionSlabWritePlatformService {
|
||||
|
||||
CommandProcessingResult createCommissionSlabs(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateCommissionSlab(Long slabId, JsonCommand command);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.OverlappingCommissionSlabException;
|
||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
|
||||
|
||||
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final CommissionSlabRepository commissionSlabRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final List<CommissionSlab> slabs = new ArrayList<>();
|
||||
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
|
||||
for (int i = 0; i < slabsArray.size(); i++) {
|
||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
||||
final CommissionSlab slab = new CommissionSlab(slabJson.get("slabType").getAsString(),
|
||||
slabJson.get("slabFrom").getAsBigDecimal(), slabJson.get("slabTo").getAsBigDecimal(),
|
||||
slabJson.get("commissionPercent").getAsBigDecimal());
|
||||
// Checked against what is already stored and against the rest of this request, since one
|
||||
// submission can just as easily carry two slabs that cover the same figure.
|
||||
rejectIfOverlapping(slab, this.commissionSlabRepository.findByType(slab.getSlabType()));
|
||||
rejectIfOverlapping(slab, slabs);
|
||||
slabs.add(slab);
|
||||
}
|
||||
|
||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
||||
} catch (final PersistenceException dve) {
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateCommissionSlab(final Long slabId, final JsonCommand command) {
|
||||
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
|
||||
final Map<String, Object> changes = slab.update(command);
|
||||
|
||||
// Re-checked after the change, against every other slab of its type. A correction can just as
|
||||
// easily create an overlap as a new row can - and the slab being corrected must not be
|
||||
// compared with itself.
|
||||
if (!changes.isEmpty()) {
|
||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
||||
rejectIfOverlapping(slab, others);
|
||||
this.commissionSlabRepository.saveAndFlush(slab);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a slab whose range overlaps one already covering part of the same figures.
|
||||
*
|
||||
* Two slabs matching the same collection volume leave the rate up to whichever row the query
|
||||
* returns first, so an agent's percentage could change between runs with nothing in the data to
|
||||
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
|
||||
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
|
||||
*/
|
||||
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||
for (final CommissionSlab other : existing) {
|
||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||
continue;
|
||||
}
|
||||
final boolean overlaps = candidate.getSlabFrom().compareTo(other.getSlabTo()) <= 0
|
||||
&& candidate.getSlabTo().compareTo(other.getSlabFrom()) >= 0;
|
||||
if (overlaps) {
|
||||
throw new OverlappingCommissionSlabException(candidate.getSlabType(), candidate.getSlabFrom(),
|
||||
candidate.getSlabTo(), other.getSlabFrom(), other.getSlabTo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.organisation.commissionslab.starter;
|
||||
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class OrganisationCommissionSlabConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
|
||||
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
|
||||
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
|
||||
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
|
||||
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
|
||||
+76
@@ -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 ────
|
||||
|
||||
/**
|
||||
|
||||
+135
-110
@@ -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());
|
||||
|
||||
+11
@@ -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;
|
||||
|
||||
|
||||
+90
-45
@@ -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) {
|
||||
|
||||
+34
@@ -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()));
|
||||
|
||||
+132
@@ -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
|
||||
|
||||
-59
@@ -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) {
|
||||
|
||||
-60
@@ -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,
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
|
||||
+8
-2
@@ -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) {
|
||||
|
||||
+739
@@ -0,0 +1,739 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Where DDS money lands in the bank's own books.
|
||||
*
|
||||
* DDS used to keep its own balances and nothing else: a customer's money, an agent's commission and
|
||||
* the cash an agent was carrying were all numbers on DDS tables that the bank's books never saw. This
|
||||
* is the one place that changes - every DDS movement that is real money goes through here, becomes a
|
||||
* transaction on a real account, and leaves a matching pair of journal entries behind.
|
||||
*
|
||||
* <p>
|
||||
* The five movements and the entries each makes:
|
||||
*
|
||||
* <pre>
|
||||
* Agent collects from a customer DR Agent Cash CR the account's control
|
||||
* Agent settles at a branch DR Cash at Vault CR Agent Cash
|
||||
* One customer pays another DR their control CR the other's control
|
||||
* Interest is posted DR Interest on Savings CR the account's control
|
||||
* Commission is paid DR Commission Expense CR the account's control
|
||||
* </pre>
|
||||
*
|
||||
* The savings module's own accounting is deliberately skipped on these deposits. Left to itself it
|
||||
* debits the product's fund source - the entry an ordinary counter deposit makes, "cash came in over
|
||||
* the counter". None of these is that: a collection is cash that reached an agent rather than a till,
|
||||
* and interest and commission are expenses the bank incurred with no cash arriving at all. So the
|
||||
* deposit is recorded without its accounting and the true pair is written here instead.
|
||||
*
|
||||
* <p>
|
||||
* Every account is looked up rather than named in code - the ledger accounts from settings a bank can
|
||||
* repoint at accounts of their own, and each customer's control account from the product's own
|
||||
* mapping. Nothing here is written if any of them is missing: it throws, and the caller's whole
|
||||
* transaction rolls back. Money that moves without an entry behind it is worse than money that has
|
||||
* not moved, because the balance changes and the books do not.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsLedger {
|
||||
|
||||
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
|
||||
public static final String AGENT_CASH = "dds-agent-cash-account";
|
||||
|
||||
/** What an agent's commission costs the bank. Set by 0491. */
|
||||
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
|
||||
|
||||
/** The savings product DDS opens customer accounts under. Set by 0492. */
|
||||
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
|
||||
|
||||
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
|
||||
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
|
||||
|
||||
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
|
||||
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
|
||||
|
||||
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
|
||||
|
||||
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
|
||||
|
||||
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
|
||||
|
||||
/**
|
||||
* The accounts money can be parked in for a customer with no account here. Every bank names its
|
||||
* Sundry account differently, so none is assumed: every open posting liability account is offered
|
||||
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
|
||||
* now owes the money - an income or expense account would book it as the bank's own. Posting,
|
||||
* because a header cannot take an entry. And never an account whose balance is meant to equal the
|
||||
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
|
||||
* account, a current-account control, an agent's own cash account - since parked money in any of
|
||||
* those would leave it no longer adding up.
|
||||
*/
|
||||
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
|
||||
+ "from acc_gl_account g "
|
||||
+ "where g.classification_enum = 2 and g.account_usage = 1 and g.disabled = false "
|
||||
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
|
||||
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
|
||||
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
|
||||
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
|
||||
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
|
||||
+ "order by g.gl_code";
|
||||
|
||||
/**
|
||||
* product_type 2 is savings. financial_account_type 2 is the control account - what the customer
|
||||
* is owed - and 1 is the fund source, the till or vault cash moves through.
|
||||
*/
|
||||
// Oldest first. A current product that shares its id with a savings product writes its mapping
|
||||
// under the same product_type, and the two sets of rows cannot be told apart; the savings product's
|
||||
// were there first, so the first row is the savings product's rather than whichever the database
|
||||
// happens to return. See 0519 for the current-account side of the same collision.
|
||||
private static final String PRODUCT_ACCOUNT_SQL = "select gl_account_id from acc_product_mapping "
|
||||
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null order by id";
|
||||
|
||||
private static final int CONTROL_ACCOUNT = 2;
|
||||
private static final int FUND_SOURCE = 1;
|
||||
|
||||
/** What interest paid to a customer costs the bank. */
|
||||
private static final int INTEREST_ON_SAVINGS = 3;
|
||||
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||
private final JournalEntryRepository journalEntryRepository;
|
||||
private final GLAccountRepository glAccountRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
/** Who a current-account deposit row is recorded as made by - see payOutToCurrentAccount. */
|
||||
private final PlatformSecurityContext context;
|
||||
private final DdsVoucherService ddsVoucherService;
|
||||
|
||||
/**
|
||||
* Runs a DDS operation and raises one voucher, in the DDS series, for everything it moved.
|
||||
* Every service that moves money reaches the ledger through here, so the voucher comes with it.
|
||||
*/
|
||||
public <T> T withVoucher(final String voucherType, final String referenceType, final Long referenceId,
|
||||
final java.util.function.Supplier<T> operation) {
|
||||
return this.ddsVoucherService.raise(voucherType, referenceType, referenceId, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* A voucher side for a move the ledger does not show: two DDS accounts both sit on DDS Customer
|
||||
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
|
||||
*/
|
||||
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
|
||||
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
|
||||
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a customer's own deposit account with what an agent collected from them.
|
||||
*
|
||||
* The money is real from the customer's side the moment the agent takes it, so it lands on their
|
||||
* account now rather than when the agent gets round to a branch. What the bank does not have yet
|
||||
* is the cash itself - it is in the agent's bag - which is what the Agent Cash side records.
|
||||
*/
|
||||
public SavingsAccountTransaction collect(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description, final GLAccount agentCashAccount) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, agentCashOr(agentCashAccount), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
|
||||
* were credited when their money was collected; this is the bank catching up with the cash.
|
||||
*/
|
||||
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount agentCashAccount) {
|
||||
final Long productId = depositProductId();
|
||||
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
|
||||
description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
|
||||
*
|
||||
* The cash is already in the bank: the cashier took it from the agent and credited the agent's GL
|
||||
* account. What moves here is who it belongs to - off the agent's account and onto what the bank
|
||||
* owes its DDS customers - so the debit is the very account the receipts credited, never looked up
|
||||
* afresh. One pair for the whole application rather than one per customer: each customer's share is
|
||||
* on their own DDS account, and a thousand identical pairs would say nothing the total does not.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account the receipts credited the agent's account clears
|
||||
* CR DDS Customer Deposits what the bank now owes the customers
|
||||
* </pre>
|
||||
*/
|
||||
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
|
||||
final String transactionId, final String description) {
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
|
||||
null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same hand-on for a party still on a savings account from before DDS kept its own: credited on
|
||||
* that account, as a collection always was, with the pair on the account's own control.
|
||||
*/
|
||||
public void applyAgentReceiptToSavings(final Party party, final LocalDate date, final BigDecimal amount,
|
||||
final GLAccount receiptAccount, final String description) {
|
||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, receiptAccount, controlAccountOf(account), description);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A party's account, wherever it is kept.
|
||||
//
|
||||
// A DDS account is DDS's own now (0514): the balance is on the party, and what the bank owes for it
|
||||
// sits on DDS Customer Deposits. A party opened before that still has a savings account behind it.
|
||||
// Each movement below asks which of the two this party has and books it there - the same entry in
|
||||
// the same direction either way, only the account that stands for "what the customer is owed"
|
||||
// differs. Callers no longer choose; they name the party.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** A collection straight onto the party's account: Dr the agent's cash, Cr what the customer is owed. */
|
||||
public void collect(final Party party, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount agentCashAccount) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
|
||||
}
|
||||
|
||||
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
|
||||
public void postInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
postInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
|
||||
}
|
||||
|
||||
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
|
||||
public void reverseInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
reverseInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* The party's whole balance paid into another account of the customer's at this bank. Nothing leaves
|
||||
* the bank: what it owed on the DDS account it now owes on theirs.
|
||||
*/
|
||||
public void payOutToOwnAccount(final Party party, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
payOutToOwnAccount(party.getSavingsAccountId(), toSavingsAccountId, date, amount, description);
|
||||
return;
|
||||
}
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* The party's whole balance paid into the customer's current account at this bank.
|
||||
*
|
||||
* Written onto the current account the way the bank's other book transfers into one are (NACH,
|
||||
* clerk disbursement): a deposit row and the balances, directly, so the current-account module
|
||||
* posts nothing of its own - and the pair here is the whole of the entry. Nothing leaves the bank:
|
||||
* what it owed on the DDS account it now owes on the current account.
|
||||
*
|
||||
* <pre>
|
||||
* DR what the customer was owed DDS Customer Deposits, or the party's savings control
|
||||
* CR the current account's control
|
||||
* </pre>
|
||||
*/
|
||||
public void payOutToCurrentAccount(final Party party, final Long currentAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final GLAccount owed;
|
||||
final Office office;
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
owed = controlAccountOf(account);
|
||||
office = account.office();
|
||||
} else {
|
||||
owed = configured(DEPOSIT_LIABILITY);
|
||||
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
}
|
||||
|
||||
creditCurrentAccount(currentAccountId, date, amount, description);
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
|
||||
|
||||
private static final String CURRENT_DEPOSIT_SQL = "insert into m_current_account_transaction "
|
||||
+ " (current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, is_manual, "
|
||||
+ " is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
|
||||
+ " values (?, ?, 1, ?, ?, false, false, false, false, ?, ?, ?, ?, current_timestamp, ?)";
|
||||
|
||||
private static final String CURRENT_BALANCE_SQL = "update m_current_account set account_balance_derived = account_balance_derived + ?, "
|
||||
+ " total_deposits_derived = coalesce(total_deposits_derived, 0) + ? where id = ?";
|
||||
|
||||
private static final String CURRENT_CONTROL_SQL = "select apm.gl_account_id from m_current_account ca "
|
||||
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
|
||||
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
|
||||
|
||||
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
|
||||
+ " where name = 'dds-current-account-control-account' and enabled = true";
|
||||
|
||||
/** A deposit row and the balances on the current account, refused unless it is active. */
|
||||
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
|
||||
if (rows.isEmpty() || ((Number) rows.get(0).get("status_enum")).intValue() != 300) {
|
||||
throw new DdsLedgerNotConfiguredException("an active current account " + currentAccountId + " to pay into");
|
||||
}
|
||||
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
|
||||
: (BigDecimal) rows.get(0).get("account_balance_derived");
|
||||
final java.sql.Date day = java.sql.Date.valueOf(date);
|
||||
final String remarks = description.length() > 490 ? description.substring(0, 490) : description;
|
||||
this.jdbcTemplate.update(CURRENT_DEPOSIT_SQL, currentAccountId, rows.get(0).get("office_id"), day, amount, // NOSONAR
|
||||
UUID.randomUUID().toString().replace("-", "").substring(0, 20), day, balance.add(amount), remarks,
|
||||
this.context.authenticatedUser().getId());
|
||||
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* The current account's control account: its product's mapping when that is the only one, and the
|
||||
* setting when the product shares an id - and so its mapping rows - with a savings product.
|
||||
*/
|
||||
private GLAccount currentAccountControl(final Long currentAccountId) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
|
||||
if (ids.size() == 1) {
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
|
||||
}
|
||||
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
|
||||
if (!override.isEmpty() && override.get(0) != null) {
|
||||
return this.glAccountRepository.findById(override.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
|
||||
}
|
||||
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
|
||||
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
|
||||
}
|
||||
|
||||
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
|
||||
public void payOutToPayable(final Party party, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount parkIn) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
|
||||
}
|
||||
|
||||
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
|
||||
public List<Map<String, Object>> sundryAccounts() {
|
||||
return this.jdbcTemplate.queryForList(SUNDRY_ACCOUNTS_SQL); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* The account to park in: the one the clerk picked, when it is on the Sundry list. There is no
|
||||
* fallback - every bank's Sundry account is its own, so none is guessed. Anything off the list is
|
||||
* refused - an income or asset account, or a customer's own deposits, would put the money
|
||||
* somewhere nobody would think to look for it.
|
||||
*/
|
||||
public GLAccount sundryAccount(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
final String message = "Pick the Sundry GL account the balance is to be parked in.";
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.required", message,
|
||||
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.required", message, "sundryGlAccountId")));
|
||||
}
|
||||
final boolean allowed = sundryAccounts().stream().anyMatch(row -> glAccountId.equals(((Number) row.get("id")).longValue()));
|
||||
if (!allowed) {
|
||||
final String message = "GL account " + glAccountId + " cannot hold a parked DDS balance - pick an open liability account that is not a customer or agent control account.";
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.invalid", message,
|
||||
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.invalid", message, "sundryGlAccountId")));
|
||||
}
|
||||
return this.glAccountRepository.findById(glAccountId).orElseThrow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Money moved from one party's account to another's.
|
||||
*
|
||||
* Two DDS accounts are both owed on DDS Customer Deposits, so the bank owes the same on the same
|
||||
* account before and after and there is nothing to book - the two DDS balances are the whole
|
||||
* movement. Only when one side is still a savings account does the money change ledger account.
|
||||
*/
|
||||
public void transfer(final Party from, final Party to, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final boolean fromSavings = from.getSavingsAccountId() != null;
|
||||
final boolean toSavings = to.getSavingsAccountId() != null;
|
||||
if (fromSavings && toSavings) {
|
||||
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
|
||||
} else if (fromSavings) {
|
||||
final SavingsAccount account = load(from.getSavingsAccountId());
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
configured(DEPOSIT_LIABILITY), description, null, account.getId());
|
||||
} else if (toSavings) {
|
||||
final SavingsAccount account = load(to.getSavingsAccountId());
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
|
||||
} else {
|
||||
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
|
||||
voucherSideOnDeposits("DR", from, amount, description);
|
||||
voucherSideOnDeposits("CR", to, amount, description);
|
||||
}
|
||||
}
|
||||
|
||||
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
|
||||
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
|
||||
final String description) {
|
||||
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
|
||||
* when they have none.
|
||||
*
|
||||
* Only the account differs. Both sides of the entry, their direction and the amount are the same
|
||||
* either way - an agent with their own account is not accounted for differently, only separately.
|
||||
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
|
||||
* set up before this existed posts exactly as they always did, and a branch can move agents onto
|
||||
* their own accounts one at a time.
|
||||
*/
|
||||
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
|
||||
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a customer's account with interest their deposits have earned.
|
||||
*
|
||||
* The interest is worked out by the DDS run on the daily product, not by the savings product -
|
||||
* which is why the product carries a zero rate - but it is paid like any other interest: an
|
||||
* expense to the bank, and more owed to the customer.
|
||||
*/
|
||||
public SavingsAccountTransaction postInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), INTEREST_ON_SAVINGS), controlAccountOf(account),
|
||||
description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes back interest a customer has been credited but, on closing early, is not entitled to.
|
||||
*
|
||||
* The exact opposite of {@link #postInterest}: the bank owes the customer less, and its interest
|
||||
* expense is reduced by what it turned out not to owe. It is written as its own movement rather
|
||||
* than by quietly paying out less, so the passbook shows the interest that was credited and the
|
||||
* part that came back - a customer who is paid a smaller figure than their last statement showed
|
||||
* can see exactly why.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance comes down
|
||||
* CR Interest on Savings the bank's interest expense is reduced
|
||||
* </pre>
|
||||
*/
|
||||
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money from one customer's account to another's.
|
||||
*
|
||||
* The bank is no better or worse off - it owed the money before and owes it after, just to
|
||||
* somebody else - so the two halves fall on the same control account and cancel. What changes is
|
||||
* which customer's balance holds it, and that is what the two account transactions record.
|
||||
*/
|
||||
public void transfer(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount from = load(fromSavingsAccountId);
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
|
||||
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
|
||||
// movement is DDS's own, so the block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(from);
|
||||
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(from, wasBlocked);
|
||||
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
|
||||
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured deposit into another account of the customer's at this bank.
|
||||
*
|
||||
* The whole balance leaves the DDS account and lands on theirs. Both are the bank's own accounts,
|
||||
* so nothing leaves the bank - it owes the same money to the same person, in a different place -
|
||||
* which is why this is the same movement as a transfer between two customers.
|
||||
*/
|
||||
public void payOutToOwnAccount(final Long fromSavingsAccountId, final Long toSavingsAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final String description) {
|
||||
transfer(fromSavingsAccountId, toSavingsAccountId, date, amount, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a matured deposit off the customer's account and parks it, ready to be sent to another
|
||||
* bank.
|
||||
*
|
||||
* The money leaves the DDS account today; the NEFT or IMPS clears later, or fails and is tried
|
||||
* again. Sending it straight out would leave a window where it has left one account and arrived
|
||||
* at none, and a failed transfer would leave nothing accounting for it at all. So it waits in the
|
||||
* payable account, which says at any moment exactly what the bank is holding and still owes.
|
||||
*
|
||||
* <pre>
|
||||
* DR the account's control the customer's balance goes
|
||||
* CR Matured Deposits Payable and the bank now owes it to them
|
||||
* </pre>
|
||||
*/
|
||||
private void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount parkIn) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
|
||||
description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a balance a party already had onto its newly opened account.
|
||||
*
|
||||
* These are customers whose money DDS held before it opened real accounts: the bank has the cash -
|
||||
* it was collected and banked over months - but its own books carry no record of either the cash
|
||||
* or what it owes them. This writes both sides at once, which is what an opening entry is for.
|
||||
*
|
||||
* <pre>
|
||||
* DR the product's fund source the cash the bank already holds
|
||||
* CR the account's control what it owes the customer
|
||||
* </pre>
|
||||
*
|
||||
* The debit assumes the cash reached the bank rather than still sitting with an agent. That holds
|
||||
* for a settled balance and is the ordinary case; a bank whose agents were mid-round when this ran
|
||||
* should reconcile it. It is the one assumption here that an accountant, not this code, should
|
||||
* confirm.
|
||||
*/
|
||||
public SavingsAccountTransaction openingBalance(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, productAccount(account.productId(), FUND_SOURCE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/** Credits an agent's own savings account with commission the bank owes them. */
|
||||
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks withdrawals on a scheme account, leaving deposits alone.
|
||||
*
|
||||
* A DDS account is a savings account, and without this a customer can empty a twelve-month
|
||||
* scheme at the counter the day after opening it - which is the one thing a daily deposit scheme
|
||||
* exists to prevent. Worse, that withdrawal happens outside DDS: the scheme balance and the
|
||||
* account balance part company, every report misstates the book, and the maturity payout later
|
||||
* fails for want of funds.
|
||||
*
|
||||
* Deposits are untouched, so the agent collects as before and interest posts as before. Only the
|
||||
* way out is closed, and only from outside - DDS lifts it for its own payouts and transfers.
|
||||
*/
|
||||
public void lockDebits(final Long savingsAccountId) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
if (!isDebitBlocked(account)) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the block for a movement DDS is making itself, saying whether it had to.
|
||||
*
|
||||
* The caller puts it back with {@link #relockDebits}. An account that was not blocked - one
|
||||
* opened before the block existed, say - is left as it is rather than being blocked for the
|
||||
* first time on its way out of the scheme.
|
||||
*/
|
||||
private boolean unlockDebitsIfBlocked(final SavingsAccount account) {
|
||||
if (!isDebitBlocked(account)) {
|
||||
return false;
|
||||
}
|
||||
account.unblockDebits();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Puts the block back after one of DDS's own withdrawals, if this call was what lifted it. */
|
||||
private void relockDebits(final SavingsAccount account, final boolean wasBlocked) {
|
||||
if (wasBlocked) {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDebitBlocked(final SavingsAccount account) {
|
||||
return SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private SavingsAccount load(final Long savingsAccountId) {
|
||||
return this.savingsAccountAssembler.assembleFrom(savingsAccountId, this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
}
|
||||
|
||||
private SavingsAccountTransaction depositWithoutAccounting(final SavingsAccount account, final LocalDate date,
|
||||
final BigDecimal amount) {
|
||||
return this.savingsAccountDomainService.handleDeposit(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, false, true,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus(), true);
|
||||
}
|
||||
|
||||
private void postPair(final SavingsAccount account, final SavingsAccountTransaction deposit, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description) {
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, debit, credit, description, deposit.getId(),
|
||||
account.getId());
|
||||
}
|
||||
|
||||
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
|
||||
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
|
||||
final Long savingsAccountId) {
|
||||
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
|
||||
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
|
||||
}
|
||||
|
||||
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
|
||||
final Long savingsTransactionId, final Long savingsAccountId) {
|
||||
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
|
||||
DdsVoucherScope.recordLedgerTransaction(transactionId);
|
||||
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
||||
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
||||
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
/** A ledger account named by a setting, so a bank can point it at one of their own. */
|
||||
private GLAccount configured(final String settingName) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(settingName);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
|
||||
}
|
||||
|
||||
private GLAccount controlAccountOf(final SavingsAccount account) {
|
||||
return productAccount(account.productId(), CONTROL_ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
|
||||
* so its ledger accounts and currency come from the product rather than from an account.
|
||||
*/
|
||||
private Long depositProductId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/** The bank's own currency, for an entry that belongs to no savings product. */
|
||||
private String organisationCurrency() {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
|
||||
if (codes.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("organisation currency");
|
||||
}
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
|
||||
private String currencyOf(final Long productId) {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
|
||||
if (codes.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
|
||||
}
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** An account off the savings product's own mapping, so this stays right per product. */
|
||||
private GLAccount productAccount(final Long productId, final int financialAccountType) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(PRODUCT_ACCOUNT_SQL, Long.class, productId, financialAccountType); // NOSONAR
|
||||
final String what = financialAccountType == CONTROL_ACCOUNT ? "control account" : "fund source";
|
||||
if (ids.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException(what + " for savings product " + productId);
|
||||
}
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException(what + " for savings product " + productId));
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Raises the voucher for a DDS operation in the bank's own voucher register.
|
||||
*
|
||||
* Nothing about vouchers is kept by DDS itself: the number, the register and its lines are the ones
|
||||
* every other module writes through {@link VoucherService}, in the same PREFIX/DDMMYYYY/NNNN form -
|
||||
* only the prefix is DDS's own, so its slips read DDS/29092026/0001 and run as a series of their own.
|
||||
*
|
||||
* An operation is wrapped in {@link #raise}. Its ledger legs and passbook lines are noted as they are
|
||||
* written (see {@link DdsVoucherScope}); when it finishes, one voucher is raised from the legs it
|
||||
* actually wrote, and its number stamped on the passbook lines and on any savings entry it made. A
|
||||
* wrap inside another adds to the outer one, so an early closure that also brings interest up to date
|
||||
* is one slip, not two.
|
||||
*
|
||||
* The voucher is raised in the operation's own transaction, so an operation that rolls back - one held
|
||||
* for a checker's approval, say - leaves no voucher behind, and the number is taken only when the money
|
||||
* actually moves.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsVoucherService {
|
||||
|
||||
/** The series DDS vouchers are numbered in. */
|
||||
public static final String PREFIX = "DDS";
|
||||
|
||||
public static final String TYPE_APPLY_RECEIPT = "DDS_APPLY_RECEIPT";
|
||||
public static final String TYPE_INTEREST = "DDS_INTEREST";
|
||||
public static final String TYPE_COMMISSION = "DDS_COMMISSION";
|
||||
public static final String TYPE_MATURITY_PAYOUT = "DDS_MATURITY_PAYOUT";
|
||||
public static final String TYPE_PREMATURE = "DDS_PREMATURE";
|
||||
public static final String TYPE_TRANSFER = "DDS_TRANSFER";
|
||||
public static final String TYPE_TRANSACTION = "DDS_TRANSACTION";
|
||||
|
||||
public static final String REF_PARTY = "DDS_PARTY";
|
||||
public static final String REF_BATCH_RUN = "DDS_BATCH_RUN";
|
||||
public static final String REF_AGENT = "DDS_AGENT";
|
||||
|
||||
/** The ledger id DdsLedger gives a leg posted for one party: DDS-PT0003-0fdd3261. */
|
||||
private static final Pattern PARTY_LEG = Pattern.compile("^DDS-(.+)-[0-9a-f]{8}$");
|
||||
|
||||
private static final String LEGS_SQL = "select je.type_enum as \"typeEnum\", je.amount, je.account_id as \"glId\", "
|
||||
+ "gl.name as \"glName\", gl.gl_code as \"glCode\", je.description, je.transaction_id as \"transactionId\", "
|
||||
+ "je.savings_transaction_id as \"savingsTxnId\" from acc_gl_journal_entry je "
|
||||
+ "join acc_gl_account gl on gl.id = je.account_id "
|
||||
+ "where je.transaction_id in (%s) and (je.reversed is null or je.reversed = false) order by je.id";
|
||||
|
||||
private static final String PARTY_SQL = "select party_name as \"name\", account_no as \"accountNo\" from m_dds_party "
|
||||
+ "where party_code = ?";
|
||||
|
||||
private static final String SAVINGS_OWNER_SQL = "select sa.id as \"savingsAccountId\", sa.account_no as \"accountNo\", "
|
||||
+ "c.display_name as \"name\", apm.gl_account_id as \"controlGlId\" from m_savings_account_transaction t "
|
||||
+ "join m_savings_account sa on sa.id = t.savings_account_id left join m_client c on c.id = sa.client_id "
|
||||
+ "left join acc_product_mapping apm on apm.product_id = sa.product_id and apm.product_type = 2 "
|
||||
+ "and apm.financial_account_type = 2 where t.id = ? order by apm.id limit 1";
|
||||
|
||||
private static final String SYSTEM_USER_SQL = "select id from m_appuser where username = 'system' and is_deleted = false limit 1";
|
||||
|
||||
/** How a voucher held for an entry awaiting approval is tied to that entry: DDS_COMMAND:{commandId}. */
|
||||
public static final String HELD_FOR_COMMAND = "DDS_COMMAND:";
|
||||
|
||||
/** The entry being approved on this thread, so the voucher held for it is the one used. */
|
||||
private static final ThreadLocal<Long> APPROVING_COMMAND = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* A voucher held for an entry that is waiting on a checker and has not been used yet. Held while
|
||||
* the entry is pending (2), or just approved (1) for a posting run that finishes in the background;
|
||||
* never one whose entry was rejected (3).
|
||||
*/
|
||||
private static final String HELD_VOUCHER_SQL = "select v.voucher_no from m_voucher v "
|
||||
+ "join m_portfolio_command_source c on v.reference_key = 'DDS_COMMAND:' || c.id and c.status in (1, 2) "
|
||||
+ "where v.voucher_no like 'DDS/%' and not exists (select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) "
|
||||
+ "and v.voucher_type = ? and v.reference_type = ? and v.reference_id = ? "
|
||||
+ "order by (v.amount = ?) desc, v.id limit 1";
|
||||
|
||||
/** The last voucher raised on this thread - read by the approvals screen to show what an approval produced. */
|
||||
private static final ThreadLocal<String> LAST_ISSUED = new ThreadLocal<>();
|
||||
|
||||
private final VoucherService voucherService;
|
||||
|
||||
/** Flushed before the voucher is raised, so every passbook line the operation wrote has its id. */
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final AppUserRepository appUserRepository;
|
||||
|
||||
/**
|
||||
* Runs one DDS operation and raises its voucher.
|
||||
*
|
||||
* @return whatever the operation returns
|
||||
*/
|
||||
public <T> T raise(final String voucherType, final String referenceType, final Long referenceId, final Supplier<T> operation) {
|
||||
if (DdsVoucherScope.current() != null) {
|
||||
// Part of a larger operation, whose voucher this becomes a part of.
|
||||
return operation.get();
|
||||
}
|
||||
LAST_ISSUED.remove();
|
||||
final DdsVoucherScope scope = DdsVoucherScope.open();
|
||||
try {
|
||||
// Any savings posting inside is DDS's own movement and part of this voucher, not a slip of its own.
|
||||
final T result = this.voucherService.withOwnVoucher(operation);
|
||||
issue(scope, voucherType, referenceType, referenceId);
|
||||
return result;
|
||||
} finally {
|
||||
DdsVoucherScope.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an entry for approval, numbering its voucher there and then.
|
||||
*
|
||||
* An entry held for a checker has moved nothing yet, but the checker approves it against a voucher
|
||||
* number - so the number is taken now, with the amount the entry is for, and the entry is tied to
|
||||
* it. When the entry is approved the money moves under that same number; when it is rejected the
|
||||
* number is marked cancelled and never used. An entry that needs no checker is carried out at
|
||||
* once and numbered as it moves, as before.
|
||||
*/
|
||||
public CommandProcessingResult submitHoldingVoucher(final String voucherType, final String referenceType, final Long referenceId,
|
||||
final Supplier<BigDecimal> amount, final Supplier<CommandProcessingResult> submit) {
|
||||
try {
|
||||
return submit.get();
|
||||
} catch (final RollbackTransactionNotApprovedException held) {
|
||||
final Long commandId = held.getResult().getCommandId();
|
||||
final AppUser user = currentUser();
|
||||
if (voucherType != null && commandId != null && user != null) {
|
||||
final BigDecimal value = amount.get();
|
||||
this.voucherService.createVoucher(voucherType, referenceType, referenceId, HELD_FOR_COMMAND + commandId,
|
||||
value == null ? BigDecimal.ZERO : value, user, PREFIX);
|
||||
}
|
||||
throw held;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs an approval so the voucher held for that entry is the one its money moves under. */
|
||||
public <T> T approving(final Long commandId, final Supplier<T> approval) {
|
||||
APPROVING_COMMAND.set(commandId);
|
||||
try {
|
||||
return approval.get();
|
||||
} finally {
|
||||
APPROVING_COMMAND.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the voucher held for a rejected entry cancelled, so it is never used. */
|
||||
public void cancelHeld(final Long commandId) {
|
||||
this.jdbcTemplate.update("update m_voucher set reference_key = reference_key || ':REJECTED' where reference_key = ?", // NOSONAR
|
||||
HELD_FOR_COMMAND + commandId);
|
||||
}
|
||||
|
||||
private String heldVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal total) {
|
||||
final Long approving = APPROVING_COMMAND.get();
|
||||
if (approving != null) {
|
||||
final List<String> exact = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select voucher_no from m_voucher v where reference_key = ? and not exists "
|
||||
+ "(select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) order by id limit 1",
|
||||
String.class, HELD_FOR_COMMAND + approving);
|
||||
if (!exact.isEmpty()) {
|
||||
return exact.get(0);
|
||||
}
|
||||
}
|
||||
if (referenceType == null || referenceId == null) {
|
||||
return null;
|
||||
}
|
||||
// Approved from elsewhere - the Checker Inbox - or a posting run finishing in the background.
|
||||
final List<String> found = this.jdbcTemplate.queryForList(HELD_VOUCHER_SQL, String.class, voucherType, referenceType, // NOSONAR
|
||||
referenceId, total);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/** What a payout or early closure of this account will move: its balance, with interest worked out but not posted. */
|
||||
public BigDecimal amountHeldBy(final Long partyId) {
|
||||
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select current_balance + coalesce(interest_calc, 0) from m_dds_party where id = ?", BigDecimal.class, partyId);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/** The voucher a posting run raises, by its job type - the same types the run itself uses. */
|
||||
public String voucherTypeOfRun(final Long batchRunId) {
|
||||
final List<String> found = this.jdbcTemplate.queryForList("select job_type from m_dds_batch_run where id = ?", String.class, // NOSONAR
|
||||
batchRunId);
|
||||
final String jobType = found.isEmpty() ? "" : found.get(0);
|
||||
return switch (jobType) {
|
||||
case "COMMISSION_POSTING" -> TYPE_COMMISSION;
|
||||
case "MATURITY_AUTO_CLOSE" -> TYPE_MATURITY_PAYOUT;
|
||||
case "INTEREST_POSTING", "OD_INTEREST_POSTING" -> TYPE_INTEREST;
|
||||
// A calculation run moves no money, so no voucher is held for it.
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** What a posting run will move. */
|
||||
public BigDecimal amountOfRun(final Long batchRunId) {
|
||||
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select total_amount from m_dds_batch_run where id = ?", // NOSONAR
|
||||
BigDecimal.class, batchRunId);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/** What an agent's day will apply: every entry of theirs still waiting to be settled. */
|
||||
public BigDecimal amountPendingFor(final Long agentId) {
|
||||
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select coalesce(sum(amount), 0) from m_dds_collection_entry where agent_id = ? and status = 'PENDING'", BigDecimal.class,
|
||||
agentId);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/** A number read from a request body, or null. */
|
||||
public static BigDecimal numberIn(final String json, final String name) {
|
||||
final JsonObject body = bodyOf(json);
|
||||
try {
|
||||
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsBigDecimal() : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A whole number read from a request body, or null. */
|
||||
public static Long idIn(final String json, final String name) {
|
||||
final JsonObject body = bodyOf(json);
|
||||
try {
|
||||
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsLong() : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject bodyOf(final String json) {
|
||||
try {
|
||||
final JsonElement element = json == null ? null : JsonParser.parseString(json);
|
||||
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
|
||||
} catch (RuntimeException e) {
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
/** The voucher the last operation on this thread raised, or null, clearing it. */
|
||||
public String takeLastIssued() {
|
||||
final String voucherNo = LAST_ISSUED.get();
|
||||
LAST_ISSUED.remove();
|
||||
return voucherNo;
|
||||
}
|
||||
|
||||
/** As {@link #raise}, for an operation that returns nothing. */
|
||||
public void raise(final String voucherType, final String referenceType, final Long referenceId, final Runnable operation) {
|
||||
raise(voucherType, referenceType, referenceId, () -> {
|
||||
operation.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void issue(final DdsVoucherScope scope, final String voucherType, final String referenceType, final Long referenceId) {
|
||||
// A passbook line saved after the operation's last ledger write would otherwise still be waiting
|
||||
// to be inserted - noted in the scope only once it is - and would miss its voucher number.
|
||||
try {
|
||||
this.entityManager.flush();
|
||||
} catch (RuntimeException notInTransaction) {
|
||||
// Nothing pending to write when there is no transaction to flush.
|
||||
}
|
||||
final Set<Long> savingsTransactions = new LinkedHashSet<>();
|
||||
final List<VoucherLine> lines = linesFromLedger(scope.getLedgerTransactionIds(), savingsTransactions);
|
||||
for (final DdsVoucherScope.Line extra : scope.getExtraLines()) {
|
||||
final VoucherLine line = new VoucherLine(extra.entryType(), extra.glAccountId(), null, null, extra.partyName(),
|
||||
extra.accountNo(), extra.amount(), null);
|
||||
lines.add(extra.description() == null ? line : line.withDescription(extra.description()));
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
// Nothing moved - a calculation run, or an operation that found nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal total = lines.stream().filter(VoucherLine::isDebit).map(VoucherLine::amount).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
final String held = heldVoucher(voucherType, referenceType, referenceId, total);
|
||||
final String voucherNo;
|
||||
if (held != null) {
|
||||
// Numbered when the entry was sent for approval, so the checker saw it; its sides are the
|
||||
// legs written now that the money has moved, and its amount what actually moved.
|
||||
this.voucherService.addLines(held, lines);
|
||||
this.jdbcTemplate.update("update m_voucher set amount = ? where voucher_no = ?", total, held); // NOSONAR
|
||||
voucherNo = held;
|
||||
} else {
|
||||
final AppUser user = currentUser();
|
||||
if (user == null) {
|
||||
throw new IllegalStateException("No user to raise the DDS voucher as - the system user is missing.");
|
||||
}
|
||||
voucherNo = this.voucherService.createVoucherWithLines(voucherType, referenceType, referenceId, user, PREFIX, lines);
|
||||
}
|
||||
LAST_ISSUED.set(voucherNo);
|
||||
|
||||
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
|
||||
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
|
||||
savingsTransactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The voucher's sides, read from the legs the operation wrote.
|
||||
*
|
||||
* A leg on a customer's own account - a DDS account, or a savings account the money was paid into -
|
||||
* is named after the customer and their account, one line each, since the slip has to show who was
|
||||
* paid what. Every other leg - interest expense, commission expense, a Sundry account - is an
|
||||
* account of the bank's, and its legs are added up into one line per side, so a posting run over
|
||||
* four hundred accounts shows one expense line against four hundred credits.
|
||||
*/
|
||||
private List<VoucherLine> linesFromLedger(final Set<String> transactionIds, final Set<Long> savingsTransactions) {
|
||||
final List<VoucherLine> customerLines = new ArrayList<>();
|
||||
final Map<String, BigDecimal> bankTotals = new LinkedHashMap<>();
|
||||
final Map<String, Map<String, Object>> bankAccounts = new LinkedHashMap<>();
|
||||
if (transactionIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
final Long depositGlId = depositLiabilityGlId();
|
||||
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
|
||||
transactionIds.toArray())) {
|
||||
final boolean debit = ((Number) leg.get("typeEnum")).intValue() == 2;
|
||||
final String side = debit ? VoucherService.ENTRY_DEBIT : VoucherService.ENTRY_CREDIT;
|
||||
final BigDecimal amount = (BigDecimal) leg.get("amount");
|
||||
final Long glId = ((Number) leg.get("glId")).longValue();
|
||||
final String description = (String) leg.get("description");
|
||||
|
||||
final Long savingsTxnId = leg.get("savingsTxnId") == null ? null : ((Number) leg.get("savingsTxnId")).longValue();
|
||||
if (savingsTxnId != null) {
|
||||
savingsTransactions.add(savingsTxnId);
|
||||
final Map<String, Object> owner = this.jdbcTemplate.queryForList(SAVINGS_OWNER_SQL, savingsTxnId).stream().findFirst() // NOSONAR
|
||||
.orElse(Map.of());
|
||||
final Object controlGl = owner.get("controlGlId");
|
||||
if (controlGl != null && glId.equals(((Number) controlGl).longValue())) {
|
||||
final Long savingsAccountId = ((Number) owner.get("savingsAccountId")).longValue();
|
||||
final VoucherLine line = debit
|
||||
? VoucherLine.debitSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount)
|
||||
: VoucherLine.creditSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount);
|
||||
customerLines.add(description == null ? line : line.withDescription(description));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
|
||||
if (partyLeg.matches() && glId.equals(depositGlId)) {
|
||||
final String partyCode = partyLeg.group(1);
|
||||
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
|
||||
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
|
||||
final String name = party.get("name") == null ? partyCode : partyCode + " · " + party.get("name");
|
||||
final VoucherLine line = new VoucherLine(side, glId, null, null, name, (String) party.get("accountNo"), amount, null);
|
||||
customerLines.add(description == null ? line : line.withDescription(description));
|
||||
continue;
|
||||
}
|
||||
|
||||
final String key = side + ":" + glId;
|
||||
bankTotals.merge(key, amount, BigDecimal::add);
|
||||
bankAccounts.putIfAbsent(key, leg);
|
||||
}
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
// Debits first, as a slip reads: where the money came from, then where it went.
|
||||
for (final String side : List.of(VoucherService.ENTRY_DEBIT, VoucherService.ENTRY_CREDIT)) {
|
||||
for (final Map.Entry<String, BigDecimal> total : bankTotals.entrySet()) {
|
||||
if (total.getKey().startsWith(side + ":")) {
|
||||
final Map<String, Object> account = bankAccounts.get(total.getKey());
|
||||
lines.add(new VoucherLine(side, ((Number) account.get("glId")).longValue(), null, null, (String) account.get("glName"),
|
||||
(String) account.get("glCode"), total.getValue(), null));
|
||||
}
|
||||
}
|
||||
for (final VoucherLine line : customerLines) {
|
||||
if (side.equals(line.entryType())) {
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
|
||||
private Long depositLiabilityGlId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add(voucherNo);
|
||||
params.addAll(ids);
|
||||
this.jdbcTemplate.update(String.format(sql, placeholders(ids)), params.toArray()); // NOSONAR
|
||||
}
|
||||
|
||||
/** Who raised it: the user at the screen, or the system user for the nightly job. */
|
||||
private AppUser currentUser() {
|
||||
final AppUser user = this.context.getAuthenticatedUserIfPresent();
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(SYSTEM_USER_SQL, Long.class); // NOSONAR
|
||||
return ids.isEmpty() ? null : this.appUserRepository.findById(ids.get(0)).orElse(null);
|
||||
}
|
||||
|
||||
private static String placeholders(final Collection<?> values) {
|
||||
return String.join(",", Collections.nCopies(values.size(), "?"));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounting.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS movement cannot say which ledger accounts it belongs in.
|
||||
*
|
||||
* The caller's whole transaction rolls back rather than the money moving anyway. A balance that
|
||||
* changes with no entry behind it is the harder thing to unpick: nobody finds out until something is
|
||||
* reconciled, and by then there is no record of what should have been written.
|
||||
*/
|
||||
public class DdsLedgerNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsLedgerNotConfiguredException(final String missing) {
|
||||
super("error.msg.dds.ledger.not.configured",
|
||||
"This cannot be posted because " + missing + " is not set up. Set it before posting DDS transactions.", missing);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/accounttypes")
|
||||
@Component
|
||||
@Tag(name = "DDS Account Types", description = "A DDS Account Type is a scheme variant a Party account is opened under - group, code, description and auto-interest-posting settings. Master data only: it holds no deposit terms, and interest is accrued off each Party's own rate by the DDS batch job.")
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSACCOUNTTYPE";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DdsAccountTypeReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve DDS Account Types", description = "Returns the list of DDS account types, ordered by code.")
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a DDS Account Type", description = "Returns the details of a single DDS account type.")
|
||||
public DdsAccountTypeData retrieveOne(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveOne(accountTypeId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a DDS Account Type", description = "Mandatory fields: accountTypeGroup, accountTypeCode, "
|
||||
+ "accountTypeDescription\n" + "\n" + "Optional fields: autoInterestPost, autoInterestPostWhen\n" + "\n"
|
||||
+ "accountTypeCode is stored upper-cased, so it stays a single identity regardless of how it was typed.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.createDdsAccountType() //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{accountTypeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a DDS Account Type", description = "Updates the details of a DDS account type.")
|
||||
public CommandProcessingResult update(
|
||||
@PathParam("accountTypeId") @Parameter(description = "accountTypeId") final Long accountTypeId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.updateDdsAccountType(accountTypeId) //
|
||||
.withJson(apiRequestBodyAsJson) //
|
||||
.build();
|
||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType, Long> {
|
||||
|
||||
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
|
||||
|
||||
/**
|
||||
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme
|
||||
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
|
||||
*/
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
|
||||
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "CREATE")
|
||||
public class CreateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createDdsAccountType(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSACCOUNTTYPE", action = "UPDATE")
|
||||
public class UpdateDdsAccountTypeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DdsAccountTypeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateDdsAccountTypeCommandHandler(final DdsAccountTypeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateDdsAccountType(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.serialization;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
|
||||
"dateFormat", "locale"));
|
||||
|
||||
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
|
||||
public DdsAccountTypeCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||
}
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element)).notBlank()
|
||||
.notExceedingLengthOf(60);
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
// Mandatory: every account opened on the scheme takes this rate.
|
||||
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
public void validateForUpdate(final String json) {
|
||||
final JsonElement element = parseAndCheckParameters(json);
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsaccounttype");
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeGroup", element)) {
|
||||
v.reset().parameter("accountTypeGroup").value(this.fromApiJsonHelper.extractStringNamed("accountTypeGroup", element))
|
||||
.notBlank().notExceedingLengthOf(60);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeCode", element)) {
|
||||
v.reset().parameter("accountTypeCode").value(this.fromApiJsonHelper.extractStringNamed("accountTypeCode", element)).notBlank()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("accountTypeDescription", element)) {
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
|
||||
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
private JsonElement parseAndCheckParameters(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
throw new InvalidJsonException();
|
||||
}
|
||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
||||
|
||||
}.getType();
|
||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
||||
return this.fromApiJsonHelper.parse(json);
|
||||
}
|
||||
|
||||
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostWhen", element)) {
|
||||
v.reset().parameter("autoInterestPostWhen")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
|
||||
v.reset().parameter("autoInterestPostDay")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
|
||||
.inMinMaxRange(1, 31);
|
||||
}
|
||||
|
||||
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
|
||||
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureLockinMonths", element)) {
|
||||
v.reset().parameter("prematureLockinMonths")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("prematureLockinMonths", element)).ignoreIfNull()
|
||||
.inMinMaxRange(0, 600);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureInterestReduction", element)) {
|
||||
v.reset().parameter("prematureInterestReduction")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureInterestReduction", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("prematureAgentCommissionPenalty", element)) {
|
||||
v.reset().parameter("prematureAgentCommissionPenalty")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("prematureAgentCommissionPenalty", element))
|
||||
.ignoreIfNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
}
|
||||
|
||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||
if (!dataValidationErrors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
|
||||
public interface DdsAccountTypeReadPlatformService {
|
||||
|
||||
Collection<DdsAccountTypeData> retrieveAll();
|
||||
|
||||
DdsAccountTypeData retrieveOne(Long accountTypeId);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.data.DdsAccountTypeData;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeReadPlatformService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final DdsAccountTypeMapper MAPPER = new DdsAccountTypeMapper();
|
||||
|
||||
private static final class DdsAccountTypeMapper implements RowMapper<DdsAccountTypeData> {
|
||||
|
||||
public String schema() {
|
||||
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
|
||||
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
|
||||
+ " t.maturity_auto_close as maturityAutoClose, "
|
||||
+ " t.premature_lockin_months as prematureLockinMonths, "
|
||||
+ " t.premature_interest_reduction as prematureInterestReduction, "
|
||||
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
|
||||
+ " t.interest_rate as interestRate, "
|
||||
+ " t.is_active as active, "
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_account_type t";
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
return DdsAccountTypeData.builder() //
|
||||
.id(rs.getLong("id")) //
|
||||
.accountTypeGroup(rs.getString("accountTypeGroup")) //
|
||||
.accountTypeCode(rs.getString("accountTypeCode")) //
|
||||
.accountTypeDescription(rs.getString("accountTypeDescription")) //
|
||||
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
|
||||
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
|
||||
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
|
||||
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
|
||||
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
|
||||
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
|
||||
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
|
||||
.interestRate(rs.getBigDecimal("interestRate")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/** JdbcSupport has no LocalDateTime helper, and these audit columns are nullable. */
|
||||
private LocalDateTime localDateTime(final ResultSet rs, final String columnName) throws SQLException {
|
||||
final Timestamp timestamp = rs.getTimestamp(columnName);
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<DdsAccountTypeData> retrieveAll() {
|
||||
final String sql = "select " + MAPPER.schema() + " order by t.account_type_code";
|
||||
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
public DdsAccountTypeData retrieveOne(final Long accountTypeId) {
|
||||
try {
|
||||
final String sql = "select " + MAPPER.schema() + " where t.id = ?";
|
||||
return this.jdbcTemplate.queryForObject(sql, MAPPER, accountTypeId); // NOSONAR
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new DdsAccountTypeNotFoundException(accountTypeId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface DdsAccountTypeWritePlatformService {
|
||||
|
||||
CommandProcessingResult createDdsAccountType(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateDdsAccountType(Long accountTypeId, JsonCommand command);
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsAccountTypeWritePlatformService {
|
||||
|
||||
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createDdsAccountType(final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
|
||||
final DdsAccountType accountType = DdsAccountType.fromJson(command);
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountType.getId()) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateDdsAccountType(final Long accountTypeId, final JsonCommand command) {
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
|
||||
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
|
||||
// The accounts on the scheme carry the code as it was before this update.
|
||||
final String codeBeforeUpdate = accountType.getAccountTypeCode();
|
||||
final Map<String, Object> changesOnly = accountType.update(command);
|
||||
|
||||
if (!changesOnly.isEmpty()) {
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
}
|
||||
|
||||
// The rate on the product is the rate: every open account on it moves to the new one, and
|
||||
// its maturity figure is worked out again. Interest already calculated stays as it was;
|
||||
// from here on it is worked out at the new rate.
|
||||
if (changesOnly.containsKey("interestRate")) {
|
||||
final List<Party> openAccounts = this.partyRepository.findActiveOnSubType(codeBeforeUpdate);
|
||||
for (final Party party : openAccounts) {
|
||||
party.takeSchemeRate(accountType.getInterestRate());
|
||||
}
|
||||
this.partyRepository.saveAll(openAccounts);
|
||||
changesOnly.put("accountsRepriced", openAccounts.size());
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountTypeId) //
|
||||
.with(changesOnly) //
|
||||
.build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
||||
return CommandProcessingResult.empty();
|
||||
} catch (final PersistenceException dve) {
|
||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
||||
handleDataIntegrityIssues(throwable, dve);
|
||||
return CommandProcessingResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
|
||||
"A DDS account type with this code already exists");
|
||||
}
|
||||
|
||||
log.error("Error occured.", dve);
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.dds.account.type.unknown.data.integrity.issue",
|
||||
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.accounttype.starter;
|
||||
|
||||
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class DdsAccountTypeConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeReadPlatformService.class)
|
||||
public DdsAccountTypeReadPlatformService ddsAccountTypeReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||
return new DdsAccountTypeReadPlatformServiceImpl(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
|
||||
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
|
||||
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
|
||||
PartyRepository partyRepository) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.approval.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
|
||||
import org.apache.fineract.portfolio.dds.approval.service.DdsApprovalReadService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The DDS approvals queue: every DDS entry waiting on a checker, in one place, with what it will do
|
||||
* and - once approved - the voucher it moved under.
|
||||
*
|
||||
* The entries are the platform's own maker-checker entries (m_portfolio_command_source); nothing is
|
||||
* queued a second way. Approving one replays it through the platform's own approval, so the money
|
||||
* moves then, and only then, and the DDS voucher is raised at that moment.
|
||||
*
|
||||
* One rule is held here more strictly than elsewhere: whoever made an entry cannot be the one to
|
||||
* approve or reject it - not even a super user, who the platform otherwise lets check their own work.
|
||||
*/
|
||||
@Path("/v1/dds/approvals")
|
||||
@Component
|
||||
@Tag(name = "DDS Approvals", description = "DDS entries waiting on a checker - transfers, payouts, early closures, receipts applied, "
|
||||
+ "postings, and changes to parties, agents and schemes.")
|
||||
@RequiredArgsConstructor
|
||||
public class DdsApprovalsApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DdsApprovalReadService readService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DdsVoucherService ddsVoucherService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "DDS entries by approval status", description = "status is PENDING (default), APPROVED or REJECTED; "
|
||||
+ "fromDate and toDate (yyyy-MM-dd) narrow it to the days the entries were made.")
|
||||
public List<Map<String, Object>> list(@QueryParam("status") @Parameter(description = "status") final String status,
|
||||
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
|
||||
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
|
||||
this.context.authenticatedUser();
|
||||
return this.readService.list(status, fromDate, toDate);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{commandId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "One DDS entry, with what it will do")
|
||||
public Map<String, Object> retrieveOne(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId) {
|
||||
this.context.authenticatedUser();
|
||||
return this.readService.retrieveOne(commandId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{commandId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve or reject a DDS entry", description = "command=approve carries the entry out and returns the "
|
||||
+ "voucher it moved under; command=reject leaves nothing done. Neither is open to the user who made the entry.")
|
||||
public Map<String, Object> action(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId,
|
||||
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final Map<String, Object> entry = this.readService.pendingEntry(commandId);
|
||||
|
||||
final Number makerId = (Number) entry.get("makerId");
|
||||
if (makerId != null && makerId.longValue() == user.getId()) {
|
||||
throw new NoAuthorizationException("You made this entry, so you cannot " + ("reject".equalsIgnoreCase(commandParam) ? "reject" : "approve")
|
||||
+ " it. Another user with checker rights has to.");
|
||||
}
|
||||
|
||||
if ("approve".equalsIgnoreCase(commandParam)) {
|
||||
this.ddsVoucherService.takeLastIssued();
|
||||
// The money moves under the voucher numbered when the entry was sent for approval.
|
||||
final CommandProcessingResult result = this.ddsVoucherService.approving(commandId,
|
||||
() -> this.commandsSourceWritePlatformService.approveEntry(commandId));
|
||||
String voucherNo = this.ddsVoucherService.takeLastIssued();
|
||||
if (voucherNo == null) {
|
||||
// A posting run finishes in the background; the voucher it will use is the one held for it.
|
||||
voucherNo = this.readService.heldVoucher(commandId);
|
||||
}
|
||||
this.readService.linkVoucher(commandId, voucherNo);
|
||||
return Map.of("commandId", commandId, "status", "APPROVED", "resourceId",
|
||||
result.getResourceId() == null ? "" : result.getResourceId(), "voucherNo", voucherNo == null ? "" : voucherNo);
|
||||
}
|
||||
if ("reject".equalsIgnoreCase(commandParam)) {
|
||||
this.commandsSourceWritePlatformService.rejectEntry(commandId);
|
||||
// The number held for it is cancelled, never used for anything else.
|
||||
final String voucherNo = this.readService.heldVoucher(commandId);
|
||||
this.ddsVoucherService.cancelHeld(commandId);
|
||||
return Map.of("commandId", commandId, "status", "REJECTED", "voucherNo", voucherNo == null ? "" : voucherNo);
|
||||
}
|
||||
throw new UnrecognizedQueryParamException("command", commandParam);
|
||||
}
|
||||
}
|
||||
+503
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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> {
|
||||
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/** Wires the Post DDS Interest job into the platform scheduler. */
|
||||
@Configuration
|
||||
public class PostDdsInterestConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Autowired
|
||||
private BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Bean
|
||||
public PostDdsInterestTasklet postDdsInterestTasklet() {
|
||||
return new PostDdsInterestTasklet(batchRunWritePlatformService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step postDdsInterestStep() {
|
||||
return new StepBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository)
|
||||
.tasklet(postDdsInterestTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job postDdsInterestJob() {
|
||||
return new JobBuilder(JobName.POST_DDS_INTEREST.name(), jobRepository).start(postDdsInterestStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.jobs.postddsinterest;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Posts DDS interest each night: the schemes whose day has come, and every account that has matured.
|
||||
*
|
||||
* The Account Type screen lets a scheme say "post interest automatically, monthly, on this day", and
|
||||
* the first pass acts on that. On the great majority of days no scheme is due and it does nothing.
|
||||
*
|
||||
* The second pass is there because that first one is not enough on its own. A scheme's posting is
|
||||
* monthly and can be switched off entirely, so a term ending between two runs leaves its last stretch
|
||||
* uncredited - and a payout made in the meantime hands the customer less than they earned. Maturity
|
||||
* is the one date that cannot wait for the next run, so accounts reaching it are brought up to date
|
||||
* here, whatever their scheme says. By morning the account is whole, and the figure the clerk reads
|
||||
* on the closure list is the figure that will be paid.
|
||||
*
|
||||
* It works off the platform's business date rather than the wall clock, so a posting always belongs to
|
||||
* the day the bank is currently working on.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PostDdsInterestTasklet implements Tasklet {
|
||||
|
||||
private final BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final int schemesPosted = this.batchRunWritePlatformService.postDueAutoInterest(businessDate);
|
||||
|
||||
if (schemesPosted == 0) {
|
||||
log.debug("Post DDS Interest: no scheme is due on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: posted interest for {} scheme(s) as on {}", schemesPosted, businessDate);
|
||||
}
|
||||
|
||||
final int maturedPosted = this.batchRunWritePlatformService.postMaturityInterest(businessDate);
|
||||
|
||||
if (maturedPosted == 0) {
|
||||
log.debug("Post DDS Interest: no matured account is short of interest on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate);
|
||||
}
|
||||
|
||||
final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
|
||||
|
||||
if (promoted == 0) {
|
||||
log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate);
|
||||
} else {
|
||||
log.info("Post DDS Interest: turned {} minor's account(s) over to their holder as on {}", promoted, businessDate);
|
||||
}
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
+152
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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);
|
||||
}
|
||||
+1057
File diff suppressed because it is too large
Load Diff
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runs a posting batch away from the request that asked for it.
|
||||
*
|
||||
* Posting credits every account on the run one at a time, and each credit costs what a savings
|
||||
* deposit costs - the account is loaded with its transactions and its running balances worked out
|
||||
* again. Measured on this build, four hundred accounts carrying ninety days of collections took two
|
||||
* minutes, and the cost climbs faster than the count: twice the accounts took four and a half times
|
||||
* as long. A branch of any size would leave the clerk watching a still screen, unable to tell a slow
|
||||
* run from a hung one, and pressing the button again.
|
||||
*
|
||||
* So the request returns as soon as the run is marked under way, and the crediting happens here. The
|
||||
* arithmetic is untouched - the same rows, the same amounts, the same ledger entries. What changes is
|
||||
* only who waits for it.
|
||||
*
|
||||
* A background thread starts with none of the context a request carries, and three pieces of it
|
||||
* matter. Without the tenant there is no database to write to at all. Without the business date the
|
||||
* postings would be dated by the wall clock rather than the day the bank is working on. Without the
|
||||
* signed-in user every row would fail its not-null audit column. All three are taken from the calling
|
||||
* thread and put back here.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DdsBatchPostingRunner {
|
||||
|
||||
/**
|
||||
* Deferred on purpose. The service hands work to this runner and the runner calls back into it,
|
||||
* so resolving either eagerly would have them waiting on each other at startup.
|
||||
*/
|
||||
private final ObjectProvider<BatchRunWritePlatformService> batchRunService;
|
||||
|
||||
@Async
|
||||
public void post(final Long batchRunId, final FineractPlatformTenant tenant, final HashMap<BusinessDateType, LocalDate> businessDates,
|
||||
final String dataSourceContext, final SecurityContext securityContext) {
|
||||
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDates);
|
||||
if (dataSourceContext != null) {
|
||||
ThreadLocalContextUtil.setDataSourceContext(dataSourceContext);
|
||||
}
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
try {
|
||||
this.batchRunService.getObject().applyAndMarkPosted(batchRunId);
|
||||
log.info("DDS batch run #{} posted", batchRunId);
|
||||
} catch (final Exception e) {
|
||||
// Recorded against the run rather than only logged: a run that dies in the background has
|
||||
// nowhere else to say so, and a clerk who is not told simply presses the button again.
|
||||
log.error("DDS batch run #{} failed while posting", batchRunId, e);
|
||||
markFailedQuietly(batchRunId, e);
|
||||
} finally {
|
||||
// The pool hands this thread on to whatever comes next, so everything set above has to go
|
||||
// - a tenant left behind would have the next run writing to the wrong bank's database.
|
||||
SecurityContextHolder.clearContext();
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the reason onto the run, in a transaction of its own.
|
||||
*
|
||||
* The one that failed has rolled back, so this needs a fresh one - and if even that cannot be
|
||||
* written there is nothing further to be done but log it, since throwing from here would lose the
|
||||
* original failure.
|
||||
*/
|
||||
private void markFailedQuietly(final Long batchRunId, final Exception cause) {
|
||||
try {
|
||||
this.batchRunService.getObject().markPostingFailed(batchRunId, cause.getMessage());
|
||||
} catch (final Exception ignored) {
|
||||
log.error("DDS batch run #{} could not record its failure", batchRunId, ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+158
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CANCEL")
|
||||
public class CancelCollectionEntryCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CancelCollectionEntryCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.cancelEntry(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "CREATE")
|
||||
public class CreateCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.createEntries(command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.collection.service.CollectionEntryWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSCOLLECTIONENTRY", action = "SETTLE")
|
||||
public class SettleCollectionEntriesCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final CollectionEntryWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public SettleCollectionEntriesCommandHandler(final CollectionEntryWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.settleAgentDay(command);
|
||||
}
|
||||
}
|
||||
+204
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
+207
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface CollectionEntryWritePlatformService {
|
||||
|
||||
/**
|
||||
* Writes down what an agent says they collected. Moves no money whatsoever.
|
||||
*
|
||||
* Accepts one entry or a round of them. The checks are the ones that can be made without the
|
||||
* cash in hand - that the account exists, is open, and is this agent's to collect from - because
|
||||
* an entry the branch will refuse hours later is better refused at the customer's door, while
|
||||
* the agent is still standing there.
|
||||
*/
|
||||
CommandProcessingResult createEntries(JsonCommand command);
|
||||
|
||||
/** Takes back a line that was never posted. Refused once the entry has been settled. */
|
||||
CommandProcessingResult cancelEntry(Long entryId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Turns an agent's outstanding entries for a date into real credits, all together.
|
||||
*
|
||||
* This is the moment the money becomes the bank's business: the clerk has the cash in front of
|
||||
* them, counted against what the entries add up to. All or none - a settlement that posted half
|
||||
* a round would leave the branch holding cash it could not account for against a list that no
|
||||
* longer matched.
|
||||
*/
|
||||
CommandProcessingResult settleAgentDay(JsonCommand command);
|
||||
}
|
||||
+476
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.collection.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);
|
||||
}
|
||||
}
|
||||
+255
@@ -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);
|
||||
}
|
||||
}
|
||||
+134
@@ -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);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a DDS party cannot be opened because no savings product has been named for DDS.
|
||||
*
|
||||
* Refusing is the point. A party created without a real account behind it is the state this whole
|
||||
* change exists to end - the customer's money would live only inside DDS again, and nobody would
|
||||
* notice until someone asked the bank's books where it was.
|
||||
*/
|
||||
public class DdsDepositProductNotConfiguredException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public DdsDepositProductNotConfiguredException() {
|
||||
super("error.msg.dds.deposit.product.not.configured",
|
||||
"No savings product is set for DDS accounts, so this customer's account cannot be opened. "
|
||||
+ "Set the 'dds-deposit-product' configuration to a savings product first.");
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party that already has a deposit account is asked to open one.
|
||||
*
|
||||
* Two accounts for one customer is worse than the one they have: collections would start splitting
|
||||
* between them, and the opening balance would be counted twice. The catch-up is meant to be run once
|
||||
* per party, and running it twice is the mistake this catches.
|
||||
*/
|
||||
public class PartyAccountAlreadyOpenException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAccountAlreadyOpenException(final String partyCode) {
|
||||
super("error.msg.dds.party.account.already.open",
|
||||
"Party " + partyCode + " already has a deposit account, so another one cannot be opened for it.", partyCode);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account that has already reached its term is put through an early closure.
|
||||
*
|
||||
* Nothing was closed early, so there is no shortfall to settle: the customer saved for the whole term
|
||||
* and has earned the interest in full. Charging for an early closure here would take money they are
|
||||
* owed, which is why this is refused rather than quietly charging nothing - the clerk has picked the
|
||||
* wrong action, and should pay the account out and close it in the ordinary way.
|
||||
*/
|
||||
public class PartyAlreadyMaturedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAlreadyMaturedException(final String partyCode, final LocalDate maturityDate) {
|
||||
super("error.msg.dds.party.already.matured",
|
||||
"Account " + partyCode + " matured on " + maturityDate
|
||||
+ ", so it cannot be closed early. Pay it out and close it in the ordinary way.",
|
||||
partyCode, maturityDate);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a party's maturity proceeds are pointed at an account that cannot receive them.
|
||||
*
|
||||
* The whole matured amount goes to this account, years after somebody typed it. Refusing it now puts
|
||||
* the problem in front of the officer who can still ask the customer; the alternative is finding out
|
||||
* on the maturity date, with the money owed and nowhere to send it.
|
||||
*/
|
||||
public class PartyCreditAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyCreditAccountUnusableException(final String accountNo, final String reason) {
|
||||
super("error.msg.dds.party.credit.account.unusable", "The maturity proceeds cannot be credited to account " + accountNo + ": "
|
||||
+ reason + ". Pick one of this customer's own open savings accounts.", accountNo, reason);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a maturity payout is attempted on a party that has no real deposit account behind it.
|
||||
*
|
||||
* There is nowhere to pay the money out of. Parties opened before DDS began opening real accounts
|
||||
* are in this state; the deposit-account catch-up brings them onto the books, and only then can they
|
||||
* be paid out and closed.
|
||||
*/
|
||||
public class PartyHasNoDepositAccountException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHasNoDepositAccountException(final String partyCode) {
|
||||
super("error.msg.dds.party.has.no.deposit.account", "Account " + partyCode
|
||||
+ " has no deposit account behind it, so there is nothing to pay out from. Open its deposit account first.", partyCode);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a matured account is put through a payout while money on it is pledged against a loan.
|
||||
*
|
||||
* A customer who needs cash before their term ends can borrow against the deposit instead of breaking
|
||||
* it: the scheme carries on, and the amount lent is marked as a lien so it cannot leave. Paying the
|
||||
* deposit out while that stands would hand the customer money the bank is holding as its security and
|
||||
* leave the loan with nothing behind it.
|
||||
*
|
||||
* So maturity waits on the loan. The term has ended and the account is owed, but it is not paid until
|
||||
* the borrowing against it is cleared and the lien released.
|
||||
*/
|
||||
public class PartyHeldUnderLienException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHeldUnderLienException(final String partyCode, final BigDecimal heldAmount) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " has " + inRupees(heldAmount) + " held under lien against a loan. "
|
||||
+ "The loan must be repaid and the lien released before this deposit can be paid out.",
|
||||
partyCode, heldAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole account is held, by the lien marked on the party itself.
|
||||
*
|
||||
* No amount, because there is none to state: DDS pays a deposit out whole or not at all, so a
|
||||
* lien on part of one would hold nothing the customer could otherwise have reached. The reason
|
||||
* takes its place, and is what the clerk needs - "held" they can already see, "held for what"
|
||||
* is the thing nobody could answer before.
|
||||
*/
|
||||
public PartyHeldUnderLienException(final String partyCode, final String reason) {
|
||||
super("error.msg.dds.party.held.under.lien",
|
||||
"Account " + partyCode + " is under lien" + because(reason)
|
||||
+ ". The lien must be released before money can leave this account.",
|
||||
partyCode, reason);
|
||||
}
|
||||
|
||||
private static String because(final String reason) {
|
||||
return reason == null || reason.isBlank() ? "" : " (" + reason.trim() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances are carried to six places, which is right for arithmetic and wrong for a sentence - a
|
||||
* clerk reading "500.000000" sees a system message, not an amount of money.
|
||||
*/
|
||||
private static String inRupees(final BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when money is to leave a minor's account after they have come of age, and their own KYC has
|
||||
* not been verified.
|
||||
*
|
||||
* Until eighteen the guardian operates the account and the bank deals with them. After it the
|
||||
* customer is the account holder in their own right, and the guardian's authority ends - so the bank
|
||||
* has nobody it may pay until it has verified who the customer now is.
|
||||
*
|
||||
* Only the way out is closed. Collections carry on, because money arriving carries no such risk and
|
||||
* a customer's saving should not stop over paperwork that is not theirs to chase.
|
||||
*/
|
||||
public class PartyMajorWithoutKycException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyMajorWithoutKycException(final String partyCode) {
|
||||
super("error.msg.dds.party.major.kyc.not.verified",
|
||||
"Account " + partyCode + " has come of age and its holder's KYC is not yet verified. "
|
||||
+ "Complete KYC before paying out - collections may continue meanwhile.",
|
||||
partyCode);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account whose term has not run out is put through a maturity payout.
|
||||
*
|
||||
* Taking the money out early is a premature withdrawal, and that is a different thing: it carries a
|
||||
* penalty and its own interest terms, agreed with the customer. Letting it through here would pay a
|
||||
* customer the full matured value on a term they have not completed.
|
||||
*/
|
||||
public class PartyNotMaturedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyNotMaturedException(final String partyCode, final LocalDate maturityDate) {
|
||||
super("error.msg.dds.party.not.matured",
|
||||
maturityDate == null
|
||||
? "Account " + partyCode + " has no maturity date, so it cannot be paid out as a matured account."
|
||||
: "Account " + partyCode + " matures on " + maturityDate
|
||||
+ ", so it cannot be paid out yet. Taking it out before then is a premature withdrawal.",
|
||||
partyCode, maturityDate);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account with nothing in it is put through a payout.
|
||||
*
|
||||
* It has already been paid, or nothing was ever collected on it. Either way there is no payment to
|
||||
* make - what it needs now is closing, which is the separate action. Letting this through would
|
||||
* write a zero-value transaction and a pair of zero journal entries that mean nothing to anybody
|
||||
* reading the account later.
|
||||
*/
|
||||
public class PartyNothingToPayOutException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyNothingToPayOutException(final String partyCode) {
|
||||
super("error.msg.dds.party.nothing.to.pay.out",
|
||||
"Account " + partyCode + " has nothing left to pay out. It can be closed instead.", partyCode);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.exception;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an account is put through an early closure before its scheme's lock-in period is up.
|
||||
*
|
||||
* Below the lock-in the bank does not want the account broken on any terms, so this is a refusal
|
||||
* rather than a charge: there is no reduced rate or agent penalty that would make it allowed. The
|
||||
* customer either waits until the date named here, or borrows against the deposit and leaves it
|
||||
* standing.
|
||||
*
|
||||
* The period is the scheme's own, not one figure for the whole bank - a two-month scheme and a
|
||||
* five-year one cannot share a lock-in without one of them becoming impossible to close early.
|
||||
*/
|
||||
public class PartyWithinLockinPeriodException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyWithinLockinPeriodException(final String partyCode, final int lockinMonths, final LocalDate openOn,
|
||||
final LocalDate closableFrom) {
|
||||
super("error.msg.dds.party.within.lockin.period",
|
||||
"Account " + partyCode + " was opened on " + openOn + " and its scheme has a lock-in of " + lockinMonths
|
||||
+ " months, so it cannot be closed early before " + closableFrom + ".",
|
||||
partyCode, lockinMonths, openOn, closableFrom);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Pays a matured DDS account out, leaving it open with a nil balance for the clerk to close.
|
||||
*
|
||||
* Its own command rather than part of CLOSE, because this one moves money - so it earns its own
|
||||
* permission, its own audit row, and its own place in a maker-checker queue if the bank wants one.
|
||||
*/
|
||||
@Service
|
||||
@CommandType(entity = "PARTY", action = "PAYOUT")
|
||||
public class PayOutPartyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final PartyWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public PayOutPartyCommandHandler(final PartyWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.payOut(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Closes 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);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.party.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Closes a DDS account before its term is up.
|
||||
*
|
||||
* Its own command rather than a flag on PAYOUT, because it is the one action here that changes what
|
||||
* the customer is owed - it settles the shortfall against either their interest or the agent's
|
||||
* commission. That earns it its own permission, its own audit row, and its own maker-checker queue.
|
||||
*/
|
||||
@Service
|
||||
@CommandType(entity = "PARTY", action = "PREMATURECLOSE")
|
||||
public class PrematureClosePartyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final PartyWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public PrematureClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.prematureClose(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.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);
|
||||
}
|
||||
}
|
||||
+645
@@ -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
Reference in New Issue
Block a user