From 231919b62b4cd562cad03a17484ac16e29664e79 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:34:54 +0530 Subject: [PATCH 01/55] fix(savings): stop crediting accounts automatically on activation Activation for Savings, RD, FD and Current accounts no longer books an opening deposit or enforces a minimum opening balance -- accounts go active at zero balance and a clerk deposits funds afterward as a separate transaction, matching how these branches actually operate. Fixed Deposit and Recurring Deposit have their own override of activateWithBalance() that a prior fix to the shared SavingsAccount base class missed, so they kept auto-depositing after Savings was already fixed. Removing that auto-deposit exposed two follow-on bugs in the FD/RD activation flow, now also fixed: - the opening deposit's journal entries were posted twice (once by the deposit helper, once by the activation command's own posting call) - FD/RD maturity amount was computed from the account's real (now empty) transaction history at activation and never recalculated once a deposit actually landed, so it showed no projected interest until the account was reloaded from a lucky code path --- .../savings/domain/FixedDepositAccount.java | 5 +- .../domain/RecurringDepositAccount.java | 5 +- .../domain/SavingsAccountAssembler.java | 17 +- ...ntTransactionWritePlatformServiceImpl.java | 14 +- ...urrentAccountWritePlatformServiceImpl.java | 162 ++---------------- ...WritePlatformServiceJpaRepositoryImpl.java | 46 ++++- ...WritePlatformServiceJpaRepositoryImpl.java | 40 ++--- .../savings/starter/SavingsConfiguration.java | 5 +- .../savings/domain/SavingsAccount.java | 11 +- 9 files changed, 88 insertions(+), 217 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 688b6feca..642fb8f08 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount { @Override public Money activateWithBalance() { - return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount()); + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of the + // deposit amount configured on the account's term/pre-closure details. + return Money.zero(this.currency); } public void close(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java index 45f24fd81..c4c07b19e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java @@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount { @Override public Money activateWithBalance() { - return Money.of(this.currency, this.minRequiredOpeningBalance); + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of any + // minimum required opening balance the product has configured. + return Money.zero(this.currency); } protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java index 95d28d2d9..4125e1df2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java @@ -19,7 +19,6 @@ package org.apache.fineract.portfolio.savings.domain; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName; @@ -455,18 +454,10 @@ public class SavingsAccountAssembler { account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element)); account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element)); - // Account Facility Config - if (element.getAsJsonObject().has(accountFacilityConfigParamName)) { - final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName); - if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) { - SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig(); - if (facilityConfig == null) { - facilityConfig = new SavingsAccountFacilityConfig(account); - account.setFacilityConfig(facilityConfig); - } - facilityConfig.updateFrom(configEl.getAsJsonObject()); - } - } + // Every digital facility starts disabled at account opening, regardless of anything the + // request sends or the product defaults to -- they're switched on deliberately, one at a + // time, later, through the account's own Edit Nominee & Features screen. + account.setFacilityConfig(new SavingsAccountFacilityConfig(account)); // Joint holders if (element.getAsJsonObject().has(jointHoldersParamName)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java index ef3d5c60b..c359556d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java @@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren "Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId); } - // Minimum required balance validation + // Minimum required balance is not enforced -- a withdrawal must never be blocked for + // taking the account below its configured minimum, only for exceeding the available + // balance (checked above). final BigDecimal newBalance = currentBalance.subtract(amount); - if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null - && account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) { - if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) { - throw new GeneralPlatformDomainRuleException( - "error.msg.current.account.min.required.balance.breached", - "Withdrawal of " + amount + " would breach the minimum required balance of " - + account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance, - accountId); - } - } final String refNo = command.stringValueOfParameterNamed("accountNumber"); final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index e4ec06386..a5a221774 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); } - // Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig" - JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig"); - if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig"); - if (fcJson != null) { - account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); - } + // Every digital facility starts disabled at account opening, regardless of anything the + // request sends or the product defaults to -- they're switched on deliberately, one at a + // time, later, through the account's own Edit Nominee & Features screen. + account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account)); // Joint holders if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { @@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri final JsonObject json = command.parsedJson().getAsJsonObject(); LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - // Validate minimum opening balance before activation - java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO; - if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) { - try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) { - // intentionally ignored - } - } - final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance(); - if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0 - && openingBalanceValue.compareTo(minRequired) < 0) { - throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException( - java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError( - "openingBalance", - "Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")", - "validation.msg.currentaccount.openingBalance.less.than.min.required", - openingBalanceValue, minRequired))); - } account.activate(activatedOnDate, user); - applyFacilityChargesOnActivation(account); - java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO; - final java.util.List depositTxns = new java.util.ArrayList<>(); - final java.util.List chargeEntries = new java.util.ArrayList<>(); - - // Opening balance deposit transaction - if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) { - try { - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction - .deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE"); - deposit.setRunningBalanceDerived(openingBalanceValue); - account.setTotalDepositsDerived(openingBalanceValue); - currentAccountTransactionRepository.saveAndFlush(deposit); - currentBalance = openingBalanceValue; - depositTxns.add(deposit); - log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId); - } catch (Exception e) { - log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage()); - } - } - - // Auto-deduct activation charges (chargeTimeEnum=3) from opening balance - final java.util.List activationCharges = - account.getCharges().stream() - .filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid()) - .toList(); - currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries); - - account.setAccountBalanceDerived(currentBalance); + // Activation posts no transaction of its own -- no opening-balance deposit, no + // digital-facility charges applied or collected, regardless of what the account's facility + // config or product defaults say. Funding and any facility charges happen strictly + // afterwards, through separate transactions a clerk processes once the account is already + // active. currentAccountRepository.saveAndFlush(account); - // Post GL journal entries — deposits in batch, each charge individually - postGlEntries(account, depositTxns, chargeEntries); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); } @@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri } } - private void applyFacilityChargesOnActivation(final CurrentAccount account) { - // Use account-level facility config — reflects what was selected at account opening time - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config = - account.getFacilityConfig(); - - log.info("[FacilityCharge] Activating account id={}, facilityConfig={}", - account.getId(), config != null ? "FOUND" : "NULL"); - - if (config == null) { - // Fallback: use product-level facility config - log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId()); - config = currentProductRepository.findById(account.getProductId()) - .map(p -> { - final var pc = p.getFacilityConfig(); - if (pc == null) { - log.info("[FacilityCharge] Product has no facility config either"); - return null; - } - // Map product config to account config fields inline - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac = - new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig(); - ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility())); - ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility())); - ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility())); - ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility())); - ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook())); - ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility())); - ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts())); - ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility())); - ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover())); - ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility())); - ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility())); - ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility())); - ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility())); - ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility())); - ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc())); - ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard())); - return ac; - }).orElse(null); - } - - if (config == null) { - log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId()); - return; - } - - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config; - log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}", - fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(), - fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(), - fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(), - fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility()); - applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY"); - applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL"); - applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY"); - applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS"); - // Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted - // when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having - // the facility enabled. - applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY"); - applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS"); - applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY"); - applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER"); - applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY"); - applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY"); - applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY"); - applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP"); - applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY"); - applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC"); - applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD"); - } - // Wrapper to carry a charge transaction together with its charge entity for GL posting private record ChargeGlEntry( org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, @@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri /** * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL - * posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and - * {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the - * charge-collection logic isn't duplicated. Returns the resulting balance. + * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) -- + * activation itself no longer collects any charges. Returns the resulting balance. */ private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, final java.util.List charges, @@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri } } - private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) { - if (!enabled) { - log.debug("[FacilityCharge] {} disabled — skip", facilityType); - return; - } - final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); - if (mappingOpt.isEmpty()) { - log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType); - return; - } - final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); - if (charge == null) { - log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType); - return; - } - log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType); - account.getCharges().add( - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge - .createFromFacility(account, charge)); - } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index dd1cd5a21..ae121f8dd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (portfolioAccountData == null) { final PaymentDetail paymentDetail = null; - this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(), - amountForDeposit.getAmount(), paymentDetail); + final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt, + account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail); + // handleFDDeposit() already posts this transaction's own journal entries internally -- + // mark it as "existing" so the postJournalEntries(...) call below (which covers other + // activation-time changes, e.g. an activation charge payment) does not post it again. + if (depositTransaction != null && depositTransaction.getId() != null) { + existingTransactionIds.add(depositTransaction.getId()); + } } else { final SavingsAccount fromSavingsAccount = null; boolean isRegularTransaction = false; @@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } final boolean isPreMatureClosure = false; - account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth); + if (amountForDeposit.isGreaterThanZero()) { + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + } else { + // No deposit has actually landed on the account yet -- project maturity using the + // configured deposit amount (the same approach used for the pre-activation preview and + // for reinvested FDs) instead of the account's real, still-empty transaction history, + // which would otherwise price in zero interest. + account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } List depositAccountOnHoldTransactions = null; if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) { depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository @@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService .retriveSavingsLinkedAssociation(savingsId); if (portfolioAccountData == null) { - this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(), - amountForDeposit.getAmount(), null, isRegularTransaction); + final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt, + account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction); + // handleRDDeposit() already posts this transaction's own journal entries internally -- + // mark it as "existing" so the postJournalEntries(...) call below (which covers other + // activation-time changes) does not post it again. + if (depositTransaction != null && depositTransaction.getId() != null) { + existingTransactionIds.add(depositTransaction.getId()); + } } else { final boolean isExceptionForBalanceCheck = false; final SavingsAccount fromSavingsAccount = null; @@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail); + // The account may have been activated with no funds yet (opening deposit is now a separate, + // later step) -- in that case its maturity amount was only ever projected off the configured + // deposit amount, with zero real transactions. Now that money has actually landed, recompute + // maturity date/amount off the account's real transaction history so it reflects reality. + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = MathContext.DECIMAL64; + final boolean isPreMatureClosure = false; + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingAccountRepositoryWrapper.saveAndFlush(account); + this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(), account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate, "DEPOSIT"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 33a14e8c0..88dd3727f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; -import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; @@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi private final GSIMRepositoy gsimRepository; private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final ErrorHandler errorHandler; - private final FacilityChargeAutoApplyService facilityChargeAutoApplyService; private final DenominationBreakdownWriter denominationBreakdownWriter; @Transactional @@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); - // Validate minimum opening balance - BigDecimal openingBalanceForValidation = BigDecimal.ZERO; - if (command.parameterExists("openingBalance")) { - final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance"); - if (ob != null) openingBalanceForValidation = ob; - } - final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance(); - if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0 - && openingBalanceForValidation.compareTo(minRequiredOpening) < 0) { - final List errors = new ArrayList<>(); - errors.add(ApiParameterError.parameterError( - "openingBalance", - "Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")", - "validation.msg.savingsaccount.openingBalance.less.than.min.required", - openingBalanceForValidation, minRequiredOpening)); - throw new PlatformApiDataValidationException(errors); - } + // Minimum required opening balance is not enforced -- activation must never be blocked + // for not meeting it; the account can be funded afterwards by whatever deposit the + // customer brings (cash or a transfer), on their own timeline. checkClientOrGroupActive(account); final Set existingTransactionIds = new HashSet<>(); @@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi final Locale locale = command.extractLocale(); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds); - // Auto-apply facility charges based on product facility config - facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account); - this.savingAccountRepositoryWrapper.saveAndFlush(account); - // Collect any newly added facility activation charges (after flush so amountOutstanding is set) - // payCharge() internally adds transactions to account.transactions via handleChargeTransactions - // Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting - facilityChargeAutoApplyService.collectActivationCharges(account); - this.savingAccountRepositoryWrapper.saveAndFlush(account); + // Activation posts no transaction of its own -- digital-facility charges are no longer + // auto-applied and auto-collected here. Whatever facilities are turned on later get + // charged (if at all) through the normal charge/collection flow at that time, not as a + // side effect of activation. } postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); @@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData); } + @Override + public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set existingTransactionIds, + final Set existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) { + postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); + } + @Override public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 45d888cdf..722d25070 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -463,8 +463,7 @@ public class SavingsConfiguration { EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, - ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService, - DenominationBreakdownWriter denominationBreakdownWriter) { + ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) { return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService, @@ -472,7 +471,7 @@ public class SavingsConfiguration { chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, - errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter); + errorHandler, denominationBreakdownWriter); } @Bean diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 791b19d4a..85b0bc8bb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -3008,7 +3008,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } public Money activateWithBalance() { - return Money.of(this.currency, this.minRequiredOpeningBalance); + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of any + // minimum required opening balance the product has configured. + return Money.zero(this.currency); } public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { @@ -3700,10 +3703,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { + // Minimum required balance is not enforced -- withdrawals (and every other balance check + // that goes through this) must never be blocked by a configured minimum balance. Overdraft + // headroom is a separate concept and still applies. Money minReqBalance = Money.zero(currency); - if (this.enforceMinRequiredBalance) { - minReqBalance = minReqBalance.plus(this.minRequiredBalance); - } if (this.allowOverdraft) { minReqBalance = minReqBalance.minus(this.overdraftLimit); } From e227e8ae0ff930d38948f58437ebbde578c416a5 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:35:18 +0530 Subject: [PATCH 02/55] fix(savings): post GL entries for facility-charge collections at edit approval Charges collected when a maker-checker Nominee/Digital-Feature edit is approved deducted the account balance and appeared in the passbook, but never posted a journal entry -- collectCharges() saved the charge transaction directly and skipped the accounting bridge entirely, so the GL never balanced against the subledger. Also fixes an unrelated NPE this exposed: collectCharges() used saveAll() instead of saveAllAndFlush(), so the new charge transaction had no id yet when the journal-entry code tried to read it. --- ...sAccountEditWritePlatformServiceJpaRepositoryImpl.java | 8 +++++++- .../savings/service/FacilityChargeAutoApplyService.java | 6 +++++- .../service/SavingsAccountWritePlatformService.java | 3 +++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index 241198c7c..ac6194011 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId()); } - // 2) Post the real charge(s) for newly-enabled facilities, today. + // 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching + // GL journal entries for those charge transactions (collectCharges() only books them + // against the account itself; it does not touch the general ledger). if (!newlyEnabledFacilityTypes.isEmpty()) { + final Set existingTransactionIds = new HashSet<>(account.findExistingTransactionIds()); + final Set existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds()); final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes); this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate()); + this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds, + existingReversedTransactionIds, false); } // 3) Apply the facility-config toggles to the real row. diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java index efb371213..d7bbf84bc 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java @@ -160,7 +160,11 @@ public class FacilityChargeAutoApplyService { } } if (!transactions.isEmpty()) { - savingsAccountTransactionRepository.saveAll(transactions); + // Flush immediately (not just save) so each transaction gets its DB-generated id right + // away -- the caller posts GL journal entries for these transactions straight after this + // call returns, and that requires a real id on each one, not one that only appears at + // end-of-transaction commit. + savingsAccountTransactionRepository.saveAllAndFlush(transactions); account.recalculateBalancesAfterFacilityCharges(transactionDate); } return transactions; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java index e6daf5577..916089a33 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformService.java @@ -79,6 +79,9 @@ public interface SavingsAccountWritePlatformService { void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set existingTransactionIds, Set existingReversedTransactionIds); + void postJournalEntriesForSavingsAccount(SavingsAccount account, Set existingTransactionIds, + Set existingReversedTransactionIds, boolean backdatedTxnsAllowedTill); + CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command); void setSubStatusInactive(Long savingsId); From 0e07e60328e8138723aff0fa10ab31bea22bc138 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:35:35 +0530 Subject: [PATCH 03/55] feat(current-accounts): add document upload/list/download/delete endpoints /v1/currentaccounts/{id}/documents had no handler at all -- the account's own /v1/currentaccounts resource matches the request path first and has no documents method, so it never fell through to the generic /v1/{entityType}/{entityId}/documents resource that every other entity type relies on. Current accounts got a 404 on every Documents-tab request. Delegates straight to the existing DocumentApiResource so current accounts get identical behaviour to clients, loans, etc. --- .../api/CurrentAccountsApiResource.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java index 61cc740d4..f084e3e97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountsApiResource.java @@ -18,6 +18,11 @@ */ package org.apache.fineract.portfolio.savings.api; +import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION; +import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE; +import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME; +import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; @@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import java.io.InputStream; import java.time.LocalDate; import java.util.Collection; import java.util.List; @@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource; +import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse; +import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData; +import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.SqlValidator; @@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.glassfish.jersey.media.multipart.FormDataParam; import org.springframework.stereotype.Component; @Path("/v1/currentaccounts") @@ -84,6 +99,10 @@ public class CurrentAccountsApiResource { private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService; private final FromJsonHelper fromApiJsonHelper; private final DefaultToApiJsonSerializer pendingEditApiJsonSerializer; + private final DocumentApiResource documentApiResource; + + /** Matches the {@code entityType} current-account document requests are stored/looked up under. */ + private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts"; @GET @Path("template") @@ -337,4 +356,54 @@ public class CurrentAccountsApiResource { return this.toApiJsonSerializer.serialize(result); } + // ── Documents ──────────────────────────────────────────────────────────── + // The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a + // chance to handle current-account requests: this class's own literal /v1/currentaccounts path + // is matched first for anything under {accountId}, and it has no "documents" method, so the + // request dead-ends in a 404 instead of falling through. These delegate straight to the same + // DocumentApiResource so current accounts get identical document-management behaviour. + + @GET + @Path("{accountId}/documents") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List documents attached to a current account") + @ApiResponse(responseCode = "200", description = "OK") + public List retrieveDocuments(@PathParam("accountId") final Long accountId) { + return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId); + } + + @GET + @Path("{accountId}/documents/{documentId}/attachment") + @Produces({ MediaType.APPLICATION_OCTET_STREAM }) + @Operation(summary = "Download a document attached to a current account") + @ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file") + public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) { + return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId); + } + + @POST + @Path("{accountId}/documents") + @Consumes({ MediaType.MULTIPART_FORM_DATA }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Attach a document to a current account") + @ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)") + public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId, + @HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream, + @FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails, + @FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart, + @FormDataParam(DOCUMENT_API_PARAM_NAME) final String name, + @FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) { + return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name, + description); + } + + @DELETE + @Path("{accountId}/documents/{documentId}") + @Operation(summary = "Remove a document attached to a current account") + @ApiResponse(responseCode = "200", description = "OK") + public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId, + @PathParam("documentId") final Long documentId) { + return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId); + } + } From 5c0a482f4926497f7c9827e8def3010fda97ee9f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:35:48 +0530 Subject: [PATCH 04/55] feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable Every mobile-number lookup was calling out to a local mock HTTP service on port 8090 that had to be started separately, so the check failed with a connection error whenever that process wasn't running. Ports the mock's full verdict table (suspected, disconnected, suspected+disconnected, clean) into MobileLookupServiceImpl itself, so the demo/test numbers resolve in-process with no HTTP call. Adds fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL call entirely for local work; every number then resolves CLEAN and is stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken for a genuine pass. Must be turned back on before UAT/production. --- .../service/MobileLookupServiceImpl.java | 62 +++++++++++++++++++ .../src/main/resources/application.properties | 7 +++ 2 files changed, 69 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java index 92878b569..bed72df0d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java @@ -52,13 +52,22 @@ public class MobileLookupServiceImpl implements MobileLookupService { "9752123456", "9752345678", "9752987654" ); + /** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */ + private static final Set DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002"); + + /** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */ + private static final Set DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003"); + private final String baseUrl; + private final boolean enabled; private final RestTemplate restTemplate; private final MobileLookupLogRepository logRepository; public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl, + @Value("${fineract.mobile-lookup.enabled:true}") boolean enabled, MobileLookupLogRepository logRepository) { this.baseUrl = baseUrl; + this.enabled = enabled; this.restTemplate = new RestTemplate(); this.logRepository = logRepository; } @@ -96,6 +105,39 @@ public class MobileLookupServiceImpl implements MobileLookupService { return MobileLookupResult.clean(logId, trimmed); } + // Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API + if (isDemoDisconnectedNumber(trimmed)) { + LOG.info("Demo disconnected number detected: {}", trimmed); + final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected( + "Number revoked and listed on MNRL (demo data)", "DEMO"); + final Long logId = saveLog(trimmed, "DISCONNECTED", false, true, + "Number is DISCONNECTED — found in MNRL records", null, source, clientId); + return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail); + } + + // Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API + if (isDemoSuspectedAndDisconnectedNumber(trimmed)) { + LOG.info("Demo suspected+disconnected number detected: {}", trimmed); + final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected( + "active", "Demo DIP suspected number", "DEMO"); + final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected( + "Number revoked and listed on MNRL (demo data)", "DEMO"); + final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true, + "Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId); + return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail); + } + + // Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external + // call entirely rather than fail with a connection error. Logged distinctly from a genuine + // CLEAN result so it can never be mistaken for one in an audit trail. + if (!enabled) { + LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed); + final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false, + "DIP/MNRL lookup is disabled by configuration — the number was not actually checked.", + "{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId); + return MobileLookupResult.clean(logId, trimmed); + } + final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed; LOG.info("Calling DIP/MNRL lookup API: {}", url); @@ -289,6 +331,26 @@ public class MobileLookupServiceImpl implements MobileLookupService { return false; } + private boolean isDemoDisconnectedNumber(final String mobileNumber) { + if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) { + return true; + } + if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) { + return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2)); + } + return false; + } + + private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) { + if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) { + return true; + } + if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) { + return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2)); + } + return false; + } + private String getStringOrNull(JsonObject json, String key) { JsonElement el = json.get(key); return (el != null && !el.isJsonNull()) ? el.getAsString() : null; diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 18fcdd30f..e39a6cdd6 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -688,6 +688,13 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72 # │ DIP/MNRL provider. Do not ship the mock. │ # └──────────────────────────────────────────────────────────────────────────┘ fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090} +# Set to false to skip the DIP/MNRL check entirely (no HTTP call at all, no need to run the mock +# service) -- every number is then logged as CLEAN but stamped "LOOKUP_DISABLED_BY_CONFIG" so it is +# never mistaken for a real pass. +# Currently OFF by default for local work so nobody needs the mock service running. Set +# FINERACT_MOBILE_LOOKUP_ENABLED=true (or flip the default below) before UAT/production, where the +# check must actually run. +fineract.mobile-lookup.enabled=${FINERACT_MOBILE_LOOKUP_ENABLED:false} # ── Login OTP (2FA) — uses LincPay credentials above ───────────────────────── fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp} From eb1aa767f73489556b883dd4653fa88e817760ba Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 20 Aug 2026 11:04:23 +0530 Subject: [PATCH 05/55] fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure Adds validation so Transfer-to-Savings can only target a real savings account, with a proper user-facing message instead of the generic validation error. Fixes three duplicate journal-entry bugs uncovered while testing FD/RD maturity: the dormant-accounts job re-posting already-closed accounts (escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure flow itself re-deriving accounting for a withdrawal that the account-transfer service had already posted. Co-Authored-By: Claude Sonnet 5 --- .../core/data/DataValidatorBuilder.java | 13 +++++ .../service/AccountingProcessorHelper.java | 5 ++ .../data/DepositAccountDataValidator.java | 48 ++++++++++++++++++- .../DepositAccountDomainServiceJpa.java | 19 +++++--- ...DepositAccountReadPlatformServiceImpl.java | 4 +- ...WritePlatformServiceJpaRepositoryImpl.java | 22 +++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 24 ++++++++++ .../data/RecurringDepositAccountData.java | 35 +++++++++----- .../SavingsAccountRepositoryWrapper.java | 5 ++ .../integrationtests/FixedDepositTest.java | 6 +-- 10 files changed, 156 insertions(+), 25 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java index b8468ebc6..69bea8cf0 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/DataValidatorBuilder.java @@ -130,6 +130,19 @@ public class DataValidatorBuilder { this.dataValidationErrors.add(error); } + /** + * Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the + * user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the + * args passed to it). + */ + public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage, + final Object... defaultUserMessageArgs) { + String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode; + final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter, + this.value, defaultUserMessageArgs); + this.dataValidationErrors.add(error); + } + public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) { String validationErrorCode = "validation.msg." + this.resource + "." + errorCode; String defaultEnglishMessage = "Failed data validation due to: " + errorCode + "."; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index c2ffffe6d..857530526 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper { accountMapping = paymentChannelSpecificAccountMapping; } } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, + CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); + } glAccount = accountMapping.getGlAccount(); } return glAccount; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java index 19a9188e7..995811ca1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java @@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -105,11 +106,14 @@ public class DepositAccountDataValidator { private final FromJsonHelper fromApiJsonHelper; private final DepositProductDataValidator productDataValidator; + private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; @Autowired - public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) { + public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator, + final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { this.fromApiJsonHelper = fromApiJsonHelper; this.productDataValidator = productDataValidator; + this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; } public void validateFixedDepositForSubmit(final String json) { @@ -351,10 +355,31 @@ public class DepositAccountDataValidator { baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull() .isOneOfTheseValues(DepositAccountOnClosureType.integerValues()); + // Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the + // moment the account matures -- before the customer has actually come in to collect it. For + // maturities processed automatically by the scheduled job (not a live counter transaction), + // that books cash as disbursed while it is still physically in the vault, so this option is + // disabled: every account must resolve to Transfer to Savings or a Reinvest option instead. + if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.withdraw.deposit.not.allowed", + "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() .longGreaterThanZero(); + + // Transfer to Savings can only ever post into a genuine Savings account -- Current accounts + // live in a separate ledger/module that this maturity-closure transfer cannot write into. + if (transferToSavingsId != null && transferToSavingsId > 0 + && !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId, + DepositAccountType.SAVINGS_DEPOSIT)) { + baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId) + .failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account", + "The selected account is not a Savings account -- Transfer to Savings can only target a Savings account."); + } } } } @@ -493,10 +518,31 @@ public class DepositAccountDataValidator { baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull() .isOneOfTheseValues(DepositAccountOnClosureType.integerValues()); + // Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the + // moment the account matures -- before the customer has actually come in to collect it. For + // maturities processed automatically by the scheduled job (not a live counter transaction), + // that books cash as disbursed while it is still physically in the vault, so this option is + // disabled: every account must resolve to Transfer to Savings or a Reinvest option instead. + if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.withdraw.deposit.not.allowed", + "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() .longGreaterThanZero(); + + // Transfer to Savings can only ever post into a genuine Savings account -- Current accounts + // live in a separate ledger/module that this maturity-closure transfer cannot write into. + if (transferToSavingsId != null && transferToSavingsId > 0 + && !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId, + DepositAccountType.SAVINGS_DEPOSIT)) { + baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId) + .failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account", + "The selected account is not a Savings account -- Transfer to Savings can only target a Savings account."); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 7d0ad1121..2ae1124a7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -555,14 +555,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi } private void updateAlreadyPostedTransactions(final Set existingTransactionIds, final SavingsAccount savingsAccount) { - List transactions = savingsAccount.getTransactions(); - int size = transactions.size(); - for (int i = size - 1;; i--) { - SavingsAccountTransaction transaction = transactions.get(i); - if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) { + // accountTransfersWritePlatformService.transferFunds() already posts accounting for the + // withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal), + // so that withdrawal must be added to existingTransactionIds here or the caller's own + // postJournalEntries() re-derives and re-posts it a second time. The previous implementation + // found it by walking the transaction list backwards from the end while the type stayed + // withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on + // a fresh fetch, not after an in-memory append, so a same-day interest posting or any other + // ordering quirk could put the new withdrawal anywhere in the list and break the walk + // silently. Scanning every transaction and checking against the original snapshot is correct + // regardless of collection order. + for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) { + if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) { existingTransactionIds.add(transaction.getId()); - } else { - break; } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 9f8b28e2f..61d8afdf2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -554,7 +554,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions, - periodFrequencyTypeOptions); + periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions); template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template, accountChart, null, null); // Re-apply CBS product fields lost during factory method object creation @@ -626,7 +626,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions, - periodFrequencyTypeOptions); + periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index ae121f8dd..79a9e8aa8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1393,6 +1393,15 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); + // Every maturity-instruction branch takes the balance out of this account through + // SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal + // entries itself. existingTransactionIds was snapshotted at the top of this method -- + // before that withdrawal existed -- so the postJournalEntries() call at the end would + // treat it as new and post it a second time. Interest postings created by + // updateMaturityStatus() above are still genuinely unposted at this point and must stay + // out of the snapshot so that final call can post them exactly once. + markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); + if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); Money amountForDeposit = account.activateWithBalance(); @@ -1421,6 +1430,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds()); } + /** + * Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a + * subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a + * maturity closure, whose withdrawal leg posts its own journal entries. + */ + private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set existingTransactionIds) { + for (final SavingsAccountTransaction transaction : account.getTransactions()) { + if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) { + existingTransactionIds.add(transaction.getId()); + } + } + } + private void postJournalEntries(final SavingsAccount savingsAccount, final Set existingTransactionIds, final Set existingReversedTransactionIds) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 88dd3727f..aad5ddcf4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1676,6 +1676,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi @Override public void setSubStatusInactive(Long savingsId) { final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); + // Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects + // pending-inactive ids from a query that requires status = ACTIVE, but the account can close + // through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting + // off the full transaction list at that point re-posts entries that closure already posted. + if (!account.isActive()) { + log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)", + savingsId); + return; + } final Set existingTransactionIds = new HashSet<>(); final Set existingReversedTransactionIds = new HashSet<>(); updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); @@ -1687,6 +1696,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi @Override public void setSubStatusDormant(Long savingsId) { final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); + if (!account.isActive()) { + log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)", + savingsId); + return; + } account.setSubStatusDormant(); this.savingAccountRepositoryWrapper.saveAndFlush(account); } @@ -1694,6 +1708,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi @Override public void escheat(Long savingsId) { final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false); + // The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an + // account can legitimately close through another path (e.g. deposit maturity) in the gap + // between that selection and this call. escheat() unconditionally forces status/sub-status + // to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so + // running it against an already-closed account both mislabels the closure reason and + // re-posts journal entries for transactions that were already posted by the real closure. + if (!account.isActive()) { + log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId); + return; + } final Set existingTransactionIds = new HashSet<>(); final Set existingReversedTransactionIds = new HashSet<>(); updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java index 690f4c275..78b18da87 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java @@ -76,6 +76,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { // for account close private final Collection onAccountClosureOptions; private final Collection paymentTypeOptions; + private final Collection maturityInstructionOptions; // import fields private transient Integer rowIndex; @@ -364,6 +365,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { this.savingsAccounts = null; this.onAccountClosureOptions = null; this.paymentTypeOptions = null; + this.maturityInstructionOptions = null; this.rowIndex = rowIndex; this.dateFormat = dateFormat; this.locale = locale; @@ -394,6 +396,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { final Collection savingsAccountDatas = null; final Integer recurringFrequency = null; final EnumOptionData recurringFrequencyType = null; + final Collection maturityInstructionOptions = null; return new RecurringDepositAccountData(depositAccountData.id, depositAccountData.accountNo, depositAccountData.externalId, depositAccountData.groupId, depositAccountData.groupName, depositAccountData.clientId, depositAccountData.clientName, @@ -416,7 +419,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, depositAccountData.withHoldTax, - depositAccountData.taxGroup); + depositAccountData.taxGroup, maturityInstructionOptions); } public static RecurringDepositAccountData withInterestChartAndRecurringDetails(final RecurringDepositAccountData account, @@ -441,7 +444,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount, account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal, account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, recurringFrequency, recurringFrequencyType, - account.withHoldTax, account.taxGroup); + account.withHoldTax, account.taxGroup, account.maturityInstructionOptions); result.copyCbsFieldsFrom(account); return result; } @@ -463,11 +466,13 @@ public final class RecurringDepositAccountData extends DepositAccountData { final Collection preClosurePenalInterestOnTypeOptions = null; final Collection periodFrequencyTypeOptions = null; + final Collection savingsAccountDatas = account.savingsAccounts; + final Collection maturityInstructionOptions = account.maturityInstructionOptions; return withTemplateOptions(account, productOptions, fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions, - preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions); + preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions); } final RecurringDepositAccountData result = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName, @@ -486,10 +491,10 @@ public final class RecurringDepositAccountData extends DepositAccountData { account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate, account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount, template.periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions, - account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount, + account.paymentTypeOptions, template.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount, account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal, account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency, - account.recurringFrequencyType, account.withHoldTax, account.taxGroup); + account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions); result.copyCbsFieldsFrom(account); // Merge accountTypeOptions and customerSegmentOptions from template if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions()); @@ -507,7 +512,8 @@ public final class RecurringDepositAccountData extends DepositAccountData { final Collection lockinPeriodFrequencyTypeOptions, final Collection withdrawalFeeTypeOptions, final Collection transactions, final Collection charges, final Collection chargeOptions, final Collection preClosurePenalInterestOnTypeOptions, - final Collection periodFrequencyTypeOptions) { + final Collection periodFrequencyTypeOptions, final Collection savingsAccountDatas, + final Collection maturityInstructionOptions) { final RecurringDepositAccountData r2 = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName, account.clientId, account.clientName, account.depositProductId, account.depositProductName, account.fieldOfficerId, @@ -524,10 +530,10 @@ public final class RecurringDepositAccountData extends DepositAccountData { account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate, account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount, periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions, - account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount, + account.paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount, account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal, account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency, - account.recurringFrequencyType, account.withHoldTax, account.taxGroup); + account.recurringFrequencyType, account.withHoldTax, account.taxGroup, maturityInstructionOptions); r2.copyCbsFieldsFrom(account); return r2; } @@ -609,6 +615,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { final EnumOptionData recurringFrequencyType = null; final boolean withHoldTax = false; final TaxGroupData taxGroup = null; + final Collection maturityInstructionOptions = null; return new RecurringDepositAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, @@ -622,7 +629,8 @@ public final class RecurringDepositAccountData extends DepositAccountData { depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount, periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, - adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup); + adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup, + maturityInstructionOptions); } public static RecurringDepositAccountData preClosureDetails(final Long accountId, final BigDecimal maturityAmount, @@ -700,6 +708,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { final EnumOptionData recurringFrequencyType = null; final boolean withHoldTax = false; final TaxGroupData taxGroup = null; + final Collection maturityInstructionOptions = null; return new RecurringDepositAccountData(accountId, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, @@ -713,7 +722,8 @@ public final class RecurringDepositAccountData extends DepositAccountData { depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount, periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, - adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup); + adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup, + maturityInstructionOptions); } public static RecurringDepositAccountData withClosureTemplateDetails(final RecurringDepositAccountData account, @@ -739,7 +749,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount, account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal, account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency, - account.recurringFrequencyType, account.withHoldTax, account.taxGroup); + account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions); closureResult.copyCbsFieldsFrom(account); return closureResult; } @@ -774,7 +784,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { final BigDecimal totalOverdueAmount, final Integer noOfOverdueInstallments, final boolean isMandatoryDeposit, final boolean allowWithdrawal, final boolean adjustAdvanceTowardsFuturePayments, final boolean isCalendarInherited, final Integer recurringFrequency, final EnumOptionData recurringFrequencyType, final boolean withHoldTax, - final TaxGroupData taxGroup) { + final TaxGroupData taxGroup, final Collection maturityInstructionOptions) { super(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldofficerId, fieldofficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, interestPostingPeriodType, @@ -818,6 +828,7 @@ public final class RecurringDepositAccountData extends DepositAccountData { // account close template options this.onAccountClosureOptions = onAccountClosureOptions; this.paymentTypeOptions = paymentTypeOptions; + this.maturityInstructionOptions = maturityInstructionOptions; } @Override diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountRepositoryWrapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountRepositoryWrapper.java index 252f85c98..0e52dfd1e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountRepositoryWrapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountRepositoryWrapper.java @@ -82,6 +82,11 @@ public class SavingsAccountRepositoryWrapper { return account; } + @Transactional(readOnly = true) + public boolean existsWithDepositAccountType(final Long savingsId, final DepositAccountType depositAccountType) { + return this.repository.findByIdAndDepositAccountType(savingsId, depositAccountType.getValue()) != null; + } + @Transactional(readOnly = true) public List findSavingAccountByClientId(@Param("clientId") Long clientId) { List accounts = this.repository.findSavingAccountByClientId(clientId); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/FixedDepositTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FixedDepositTest.java index a55e312cc..0367baacd 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/FixedDepositTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/FixedDepositTest.java @@ -172,7 +172,7 @@ public class FixedDepositTest extends IntegrationTest { moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class); moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP)); fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl( - new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper()); + new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper()); try { HashMap h = fixedDepositAccountInterestCalculationServiceImpl .calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper())); @@ -198,7 +198,7 @@ public class FixedDepositTest extends IntegrationTest { moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class); moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP)); fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl( - new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper()); + new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper()); try { HashMap h = fixedDepositAccountInterestCalculationServiceImpl .calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper())); @@ -224,7 +224,7 @@ public class FixedDepositTest extends IntegrationTest { moneyHelperStatic = Mockito.mockStatic(MoneyHelper.class); moneyHelperStatic.when(() -> MoneyHelper.getMathContext()).thenReturn(new MathContext(12, RoundingMode.UP)); fixedDepositAccountInterestCalculationServiceImpl = new FixedDepositAccountInterestCalculationServiceImpl( - new DepositAccountDataValidator(new FromJsonHelper(), null), new FromJsonHelper()); + new DepositAccountDataValidator(new FromJsonHelper(), null, null), new FromJsonHelper()); BigDecimal expectedResult = new BigDecimal("10506.250000"); BigDecimal actualResult = new BigDecimal(fixedDepositAccountInterestCalculationServiceImpl .calculateInterest(new JsonQuery(apiRequestBodyAsJson, element, new FromJsonHelper())).get("maturityAmount").toString()); From 20fe11e60dafb5638d58d9c43fc7545b094c25bf Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 21 Aug 2026 12:57:02 +0530 Subject: [PATCH 06/55] fix(deposits): correct FD reinvestment on maturity and unblock product edit submission Reinvestment on maturity was creating the new deposit with a zero balance, because it sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to stop a manually opened FD from auto-crediting itself. It now carries over what reInvest() actually put on the new account. Both legs of the renewal are also posted as an internal transfer, so the funds move through the transfer-suspense GL instead of appearing to leave and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the renewed term. Adds a reinvestment trail: the new account records which account it came from, and the account view resolves both directions of the link so either side can be traced. Also fixes product edit submission, which failed outright for any product carrying payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving gl_account_id null and tripping the satellite table's NOT NULL constraint. Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/DepositAccountDomainService.java | 8 ++++ .../DepositAccountDomainServiceJpa.java | 43 ++++++++++++++++-- .../savings/domain/FixedDepositAccount.java | 20 ++++++++- ...DepositAccountReadPlatformServiceImpl.java | 40 +++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 14 +++++- .../db/changelog/tenant/changelog-tenant.xml | 1 + ...70_add_reinvested_from_to_deposit_term.xml | 30 +++++++++++++ .../savings/data/FixedDepositAccountData.java | 21 +++++++++ .../DepositAccountTermAndPreClosure.java | 15 +++++++ .../domain/ProductPendingEditFieldMapper.java | 44 ++++++++++++++----- 10 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java index 3b29b900c..aff6abadd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java @@ -35,6 +35,14 @@ public interface DepositAccountDomainService { SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail); + /** + * As above, but lets the caller declare the deposit as an internal account-to-account movement rather + * than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight + * from the matured deposit and must post against the transfer-suspense GL instead of cash/vault. + */ + SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, + BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer); + SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate, BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 2ae1124a7..05f48a177 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -86,7 +86,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, final boolean applyWithdrawFee, final boolean isRegularTransaction) { - boolean isAccountTransfer = false; + final boolean isAccountTransfer = false; + return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction, + isAccountTransfer); + } + + /** + * As above, but lets the caller declare the withdrawal as an internal account-to-account movement + * rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance + * moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault. + */ + private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt, + final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, + final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) { boolean isInterestTransfer = false; boolean isWithdrawBalance = false; final boolean backdatedTxnsAllowedTill = false; @@ -100,7 +112,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi @Override public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) { - boolean isAccountTransfer = false; + final boolean isAccountTransfer = false; + return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer); + } + + @Transactional + @Override + public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt, + final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, + final boolean isAccountTransfer) { boolean isRegularTransaction = false; final boolean backdatedTxnsAllowedTill = false; return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, @@ -207,8 +227,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); autoGenerateAccountNumber(reinvestedDeposit); + // Renewal is an internal movement into the new deposit, not cash paid out -- post it + // against the transfer-suspense GL rather than crediting cash/vault. + final boolean isInternalTransferLeg = true; final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), - paymentDetail, false, isRegularTransaction); + paymentDetail, false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); } else if (onClosureType.isTransferToSavings()) { final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); @@ -276,9 +299,17 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); autoGenerateAccountNumber(reinvestedDeposit); + // Renewal moves the balance internally into the new deposit -- no cash leaves the branch -- + // so this leg is posted as an account transfer (against the transfer-suspense GL) rather + // than as a counter withdrawal, which would wrongly credit the cash/vault account. + final boolean isInternalTransferLeg = true; final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction); + false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); + // handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as + // already accounted for -- exactly as the transfer-to-savings branch below does -- or the + // postJournalEntries() call at the end of this method posts it a second time. + updateAlreadyPostedTransactions(existingTransactionIds, account); if (onClosureType.isReinvestPrincipalAndInterest()) { account.updateClosedStatus(); @@ -304,6 +335,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); } // if(!processMaturityInstructionOnly) @@ -474,6 +506,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + // handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already + // accounted for or the postJournalEntries() call below posts it a second time. + updateAlreadyPostedTransactions(existingTransactionIds, account); } account.prematureClosure(user, command, changes); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 642fb8f08..95cff6e3f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -829,7 +829,12 @@ public class FixedDepositAccount extends SavingsAccount { final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType .fromInt(this.interestCalculationDaysInYearType); final BigDecimal minRequiredOpeningBalance = null; - final BigDecimal interestRate = BigDecimal.ZERO; + // Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses + // for a brand-new account -- so a product with no chart slab covering this tenure/amount + // doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still + // re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the + // original account was opened) whenever a matching slab exists. + final BigDecimal interestRate = product.nominalAnnualInterestRate(); final Set savingsAccountCharges = null; final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; @@ -845,6 +850,15 @@ public class FixedDepositAccount extends SavingsAccount { newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount); newChart.updateDepositAccountReference(reInvestedAccount); + // Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's + // reinvested FD silently drops back to the standard rate on the new term. + reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen()); + reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate()); + + // Track which account this was reinvested from, so staff/UI can trace the two accounts to + // each other instead of the link disappearing the moment the old one closes. + newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId()); + return reInvestedAccount; } @@ -886,7 +900,9 @@ public class FixedDepositAccount extends SavingsAccount { @Override public void loadLazyCollections() { super.loadLazyCollections(); - this.chart.getId(); + if (this.chart != null) { + this.chart.getId(); + } } public BigDecimal getDepositAmount() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 61d8afdf2..130b1fb60 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead ((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders); } } + if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) { + applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId); + } } return account; @@ -329,6 +332,43 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead } } + /** + * Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the + * account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest + * Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW + * account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row + * points at it. + */ + private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result, + final Long accountId) { + final String fromSql = "select src.id as id, src.account_no as accountNo " + + "from m_deposit_account_term_and_preclosure t " + + "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?"; + final String intoSql = "select dest.id as id, dest.account_no as accountNo " + + "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id " + + "where t.reinvested_from_savings_id = ?"; + + Long fromId = null; + String fromNo = null; + final java.util.List> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId); + if (!fromRows.isEmpty()) { + fromId = ((Number) fromRows.get(0).get("id")).longValue(); + fromNo = (String) fromRows.get(0).get("accountNo"); + } + + Long intoId = null; + String intoNo = null; + final java.util.List> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId); + if (!intoRows.isEmpty()) { + intoId = ((Number) intoRows.get(0).get("id")).longValue(); + intoNo = (String) intoRows.get(0).get("accountNo"); + } + + if (fromId != null || intoId != null) { + result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo); + } + } + private java.util.List> retrieveJointHolders(final Long accountId) { final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 79a9e8aa8..fe4c4a6c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1404,16 +1404,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); - Money amountForDeposit = account.activateWithBalance(); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); + // activateWithBalance() always returns zero here -- it exists to stop a manually + // opened FD from auto-crediting itself at activation (funding is supposed to be a + // separate, clerk-posted deposit). A reinvestment isn't that: the balance was just + // withdrawn from the matured account and needs to land in the new one as part of + // this same automated closure, so it has to come from what reInvest() actually + // carried over onto the new account, not from that unrelated activation guard. + Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); Money activationChargeAmount = getActivationCharge(reinvestAccount); if (activationChargeAmount.isGreaterThanZero()) { payActivationCharge(reinvestAccount); amountForDeposit = amountForDeposit.plus(activationChargeAmount); } + // Matching internal-transfer leg for the withdrawal the closure just made on the + // matured account: the funds never touch cash, so this posts against the + // transfer-suspense GL instead of debiting cash/vault. + final boolean isInternalTransferLeg = true; this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(), - amountForDeposit.getAmount(), null); + amountForDeposit.getAmount(), null, isInternalTransferLeg); } } } else if (depositAccountType.isRecurringDeposit()) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d2407861e..22f8bf57a 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -537,4 +537,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml new file mode 100644 index 000000000..9e5c62f6c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_reinvested_from_to_deposit_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index 8bea46e3a..d67d12baf 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -138,6 +138,23 @@ public final class FixedDepositAccountData extends DepositAccountData { private Boolean isSeniorCitizen; private java.math.BigDecimal seniorCitizenInterestRate; + // Reinvestment trail: which account this one was reinvested FROM (set on the new account) and + // which account it was reinvested INTO (derived by reverse lookup on the old account), so staff + // can trace a "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure both directions + // instead of the link disappearing once the old account closes. + private Long reinvestedFromAccountId; + private String reinvestedFromAccountNo; + private Long reinvestedIntoAccountId; + private String reinvestedIntoAccountNo; + + public void setReinvestmentLinks(final Long reinvestedFromAccountId, final String reinvestedFromAccountNo, + final Long reinvestedIntoAccountId, final String reinvestedIntoAccountNo) { + this.reinvestedFromAccountId = reinvestedFromAccountId; + this.reinvestedFromAccountNo = reinvestedFromAccountNo; + this.reinvestedIntoAccountId = reinvestedIntoAccountId; + this.reinvestedIntoAccountNo = reinvestedIntoAccountNo; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; @@ -329,6 +346,10 @@ public final class FixedDepositAccountData extends DepositAccountData { this.jointHolders = src.jointHolders; this.isSeniorCitizen = src.isSeniorCitizen; this.seniorCitizenInterestRate = src.seniorCitizenInterestRate; + this.reinvestedFromAccountId = src.reinvestedFromAccountId; + this.reinvestedFromAccountNo = src.reinvestedFromAccountNo; + this.reinvestedIntoAccountId = src.reinvestedIntoAccountId; + this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo; this.subStatus = src.subStatus; this.reasonForBlock = src.reasonForBlock; this.lastActiveTransactionDate = src.lastActiveTransactionDate; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index eb2fc6022..37b3db921 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -89,6 +89,21 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom extractGlArrayMappings(final JsonCommand command, final ProductPendingEdit edit) { final List mappings = new java.util.ArrayList<>(); boolean any = false; any |= extractGlArray(command, edit, "paymentChannelToFundSourceMappings", ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, - "paymentTypeId", mappings); - any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", mappings); + "paymentTypeId", PAYMENT_CHANNEL_GL_FIELD, mappings); + any |= extractGlArray(command, edit, "feeToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_FEE, "chargeId", + INCOME_ACCOUNT_GL_FIELD, mappings); any |= extractGlArray(command, edit, "penaltyToIncomeAccountMappings", ProductPendingEditGlMapping.TYPE_PENALTY, "chargeId", - mappings); + INCOME_ACCOUNT_GL_FIELD, mappings); return any ? mappings : null; } private static boolean extractGlArray(final JsonCommand command, final ProductPendingEdit edit, final String paramName, - final String mappingType, final String idFieldName, final List out) { + final String mappingType, final String idFieldName, final String glFieldName, + final List out) { if (!command.hasParameter(paramName)) { return false; } @@ -625,8 +635,13 @@ public final class ProductPendingEditFieldMapper { if (raw != null && raw.isJsonArray()) { for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) { final JsonObject o = el.getAsJsonObject(); - final Long idFieldValue = o.has(idFieldName) ? o.get(idFieldName).getAsLong() : null; - final Long glAccountId = o.has("glAccountId") ? o.get("glAccountId").getAsLong() : null; + final Long idFieldValue = longOrNull(o, idFieldName); + final Long glAccountId = longOrNull(o, glFieldName); + // A mapping is only meaningful with both halves present; keeping a half-filled row + // would fail the satellite table's NOT NULL constraint and abort the whole edit. + if (idFieldValue == null || glAccountId == null) { + continue; + } final Long paymentTypeId = "paymentTypeId".equals(idFieldName) ? idFieldValue : null; final Long chargeId = "chargeId".equals(idFieldName) ? idFieldValue : null; out.add(new ProductPendingEditGlMapping(edit, mappingType, paymentTypeId, chargeId, glAccountId)); @@ -635,15 +650,22 @@ public final class ProductPendingEditFieldMapper { return true; } + private static Long longOrNull(final JsonObject o, final String fieldName) { + final com.google.gson.JsonElement el = o.get(fieldName); + return el == null || el.isJsonNull() ? null : el.getAsLong(); + } + public static void writeGlArrayMappingsToJson(final JsonObject root, final List mappings) { writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PAYMENT_CHANNEL, "paymentChannelToFundSourceMappings", - "paymentTypeId"); - writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId"); - writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId"); + "paymentTypeId", PAYMENT_CHANNEL_GL_FIELD); + writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_FEE, "feeToIncomeAccountMappings", "chargeId", + INCOME_ACCOUNT_GL_FIELD); + writeGlArrayGroup(root, mappings, ProductPendingEditGlMapping.TYPE_PENALTY, "penaltyToIncomeAccountMappings", "chargeId", + INCOME_ACCOUNT_GL_FIELD); } private static void writeGlArrayGroup(final JsonObject root, final List mappings, final String type, - final String paramName, final String idFieldName) { + final String paramName, final String idFieldName, final String glFieldName) { final JsonArray array = new JsonArray(); for (final ProductPendingEditGlMapping mapping : mappings) { if (!type.equals(mapping.getMappingType())) { @@ -652,7 +674,7 @@ public final class ProductPendingEditFieldMapper { final JsonObject entry = new JsonObject(); final Long idFieldValue = "paymentTypeId".equals(idFieldName) ? mapping.getPaymentTypeId() : mapping.getChargeId(); entry.addProperty(idFieldName, idFieldValue); - entry.addProperty("glAccountId", mapping.getGlAccountId()); + entry.addProperty(glFieldName, mapping.getGlAccountId()); array.add(entry); } root.add(paramName, array); From 46f5a1babd22f685284ff0bfb8cf6646fc273fe1 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 23 Aug 2026 16:44:11 +0530 Subject: [PATCH 07/55] feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action WHAT THIS CHANGES ================== Three related FD (Fixed Deposit) features, built incrementally in one session: 1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and Interest", the SAME m_savings_account row is now reused across every renewal (account number never changes) instead of closing the account and opening a new one. FixedDepositAccount.renewInPlace() does this by setting start_interest_calculation_date as a pivot: interest for terms before the pivot is never recalculated, only the new term's window is computed from there. A new one-to-many audit table m_fd_renewal_history records one row per term (renewal_no 0,1,2...) with that term's own principal, rate, dates, interest earned, maturity, and status. A companion table m_fd_renewal_chart_slab_history archives each closing term's full rate-by-tenure chart, captured just before renewInPlace overwrites it. 2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master (m_cbs_base_interest_rate_slab_history) was the only place that ever stored a senior-citizen premium. It is now also persisted on m_savings_account_interest_rate_slab (new senior_citizen_rate column, nullable, added only to this account-level table -- NOT to the shared InterestRateChartSlabFields embeddable, so the product-level table m_interest_rate_slab is untouched). Applied both at account creation (DepositAccountAssembler now prices a new FD off the active CBS base version when one exists for that FD type, falling back to the product's own chart otherwise) and at renewal (renewInPlace refreshes the chart from the same base version). 3. PAYMENT ACTION -- a new manual command, POST /fixeddepositaccounts/{id}?command=payment, available whether the FD is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest Principal and Interest (via renewInPlace), Re-Invest Principal Only (legacy close-and-reopen, now also transfers the leftover interest to a chosen savings account instead of stranding it), and a new closure type PARTIAL_REINVEST (500) -- part of the matured balance renews in place on the same account, the remainder transfers to a savings account picked at request time. Withdraw Deposit is deliberately excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT plus its CHECKER twin were added in migration 0478. KEY FILES ========= - DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new), renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate matching with unit conversion across days/weeks and months/years families. - FixedDepositAccount.java: renewInPlace, validateRenewalDate, isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking. - SavingsAccount.java (SHARED with Savings and RD): added findLastTransactionBefore -- the interest-posting opening balance for a pivot-dated account now seeds from the last transaction strictly before the pivot, not the one on it. This fixes a bug where a renewed term could accrue zero interest, or double-subtract a same-day withdrawal, depending on transaction list order. Also widened calculateDateAccountIsLockedUntil to protected so renewInPlace can refresh lock-in on each renewed term. - DepositAccountTransactionDataValidator.java: new validatePayment. validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can never reach the Close or Premature-Close handlers, which have no branch for it and would otherwise pay the whole balance out in cash. This applies to both FD and RD, since they share validateClosing. - DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate (Close and Premature Close, excludes PARTIAL_REINVEST) split from the new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one private method with a flag. The renewal-history fallback query, for chains that predate m_fd_renewal_history, was rewritten to emit the exact same column shape as the primary query. - New: FixedDepositRenewalHistory plus its repository, FixedDepositRenewalChartSlabHistory plus its repository, PaymentFixedDepositAccountCommandHandler. - Migrations 0471 through 0478 under tenant/parts, each registered once in changelog-tenant.xml. RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE ============================================================ RD and plain Savings share several of the touched files. Verified during this work: - handleRDAccountClosure and RecurringDepositAccount.java were not modified. - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD and RD close and prematureClose commands, and excluded from their template option lists. This was a real regression caught mid-session: the enum value briefly leaked into RD's Close dropdown as a working looking option that actually paid the whole RD balance out in cash. It was found and fixed before this commit. - The SavingsAccount.calculateInterestUsing opening-balance change only affects accounts where start_interest_calculation_date differs from activatedon_date. On the dev DB this was exactly two accounts, both FD test accounts created this session. Before deploying, run: SELECT deposit_type_enum, COUNT(*) FROM m_savings_account WHERE start_interest_calculation_date IS NOT NULL AND start_interest_calculation_date <> activatedon_date GROUP BY deposit_type_enum; Any non-FD rows (100 or 300) there mean a Savings or RD account will get a different, corrected interest amount than before. This is expected to be rare or zero, but confirm before deploy. - DepositAccountAssembler's creation-time base-rate pricing is guarded by depositAccountType.isFixedDeposit, so RD account creation is unaffected. - is_active on InterestRateChart (migration 0474) is read only by FixedDepositProduct.activeChart, which only FD's renewInPlace and reInvest call. RD's applicableChart path is untouched. Migration 0474 also retires (is_active = false) any chart whose end_date is already past. On the dev DB this retired zero rows; check on the target DB before deploy, since there is currently no API to reactivate a retired chart. KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING ============================================================ - Migrations 0471 through 0477, the DDL ones, have never actually executed via Liquibase. They were mirrored by hand directly into the dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so testing could proceed without a full rebuild each time. Liquibase will mark them MARK_RAN via preConditions on the dev DB's next real run, but their first genuine execution will be on whatever environment does not already have these columns and tables, such as a fresh QA or production database. Test a migration-from-scratch run before relying on this in production. - This code has not been compiled. Every new or changed method signature, import, and call site was manually cross-checked with grep against real declarations, but that is not a substitute for gradle actually compiling it. Run a full build before deploying and treat any compiler error as expected until confirmed clean. - DepositAccountAssembler pricing every new FD off the active CBS base rate master, instead of the product's own chart, whenever one exists is a real behaviour change affecting all new FD accounts of a type that has a base-rate version, not just senior-citizen ones. Flagging this for explicit product sign-off, not assuming it in this commit. Co-Authored-By: Claude Sonnet 5 --- .../service/CommandWrapperBuilder.java | 9 + .../data/SavingsAccountSummaryData.java | 17 +- .../savings/DepositAccountOnClosureType.java | 8 +- .../savings/DepositsApiConstants.java | 1 + .../savings/service/SavingsEnumerations.java | 4 + ...sReadPlatformServiceJpaRepositoryImpl.java | 19 +- .../api/FixedDepositAccountsApiResource.java | 7 +- .../data/DepositAccountDataValidator.java | 18 + ...epositAccountTransactionDataValidator.java | 84 ++- .../domain/DepositAccountAssembler.java | 30 +- .../domain/DepositAccountDomainService.java | 3 + .../DepositAccountDomainServiceJpa.java | 593 +++++++++++++++++- .../savings/domain/FixedDepositAccount.java | 284 ++++++++- .../FixedDepositRenewalChartSlabHistory.java | 99 +++ ...ositRenewalChartSlabHistoryRepository.java | 21 + .../domain/FixedDepositRenewalHistory.java | 158 +++++ .../FixedDepositRenewalHistoryRepository.java | 30 + ...mentFixedDepositAccountCommandHandler.java | 46 ++ ...erestRateChartReadPlatformServiceImpl.java | 4 +- .../DepositAccountReadPlatformService.java | 6 + ...DepositAccountReadPlatformServiceImpl.java | 141 ++++- ...WritePlatformServiceJpaRepositoryImpl.java | 62 ++ .../db/changelog/tenant/changelog-tenant.xml | 8 + ..._add_fd_account_number_to_deposit_term.xml | 24 + ...2_add_renewal_cycle_no_to_deposit_term.xml | 23 + .../parts/0473_create_fd_renewal_history.xml | 81 +++ ...4_add_is_active_to_interest_rate_chart.xml | 40 ++ ...5_create_fd_renewal_chart_slab_history.xml | 50 ++ ...ior_citizen_rate_to_account_chart_slab.xml | 27 + ..._rate_to_fd_renewal_chart_slab_history.xml | 21 + .../parts/0478_add_fd_payment_permission.xml | 29 + .../domain/InterestRateChart.java | 23 + .../domain/InterestRateChartFields.java | 4 + .../domain/InterestRateChartSlabFields.java | 4 + ...positAccountInterestRateChartSlabData.java | 22 +- .../savings/data/FixedDepositAccountData.java | 30 + .../DepositAccountInterestRateChart.java | 86 +++ .../DepositAccountInterestRateChartSlabs.java | 29 + .../DepositAccountTermAndPreClosure.java | 44 ++ .../savings/domain/FixedDepositProduct.java | 36 ++ .../savings/domain/SavingsAccount.java | 38 +- .../DepositAccountWritePlatformService.java | 2 + 42 files changed, 2215 insertions(+), 50 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 5e2c31402..ae8f7d0b9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -2897,6 +2897,15 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) { + this.actionName = "PAYMENT"; + this.entityName = "FIXEDDEPOSITACCOUNT"; + this.entityId = accountId; + this.savingsId = accountId; + this.href = "/fixeddepositaccounts/" + accountId + "?command=payment"; + return this; + } + public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) { this.actionName = "CALCULATEINTEREST"; this.entityName = "FIXEDDEPOSITACCOUNT"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java index 009e634eb..191a8d7af 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java @@ -72,11 +72,26 @@ public class SavingsAccountSummaryData { // differentiate deposit accounts Savings, FD and RD accounts private final EnumOptionData depositType; + /** + * For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal + * chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo + * for an FD that has never been renewed, and is null for non-deposit accounts. + */ + private final String fdAccountNumber; + /** + * Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so + * on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately. + */ + private final Integer renewalCycleNo; + public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId, final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency, final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold, final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline, - final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) { + final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate, + final String fdAccountNumber, final Integer renewalCycleNo) { + this.fdAccountNumber = fdAccountNumber; + this.renewalCycleNo = renewalCycleNo; this.id = id; this.accountNo = accountNo; this.externalId = externalId; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java index f5067ae6c..c4939917b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java @@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType { WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), // TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), // REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), // - REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); // + REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), // + PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); // private final Integer value; private final String code; @@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType { case 200 -> TRANSFER_TO_SAVINGS; case 300 -> REINVEST_PRINCIPAL_AND_INTEREST; case 400 -> REINVEST_PRINCIPAL_ONLY; + case 500 -> PARTIAL_REINVEST; default -> INVALID; }; } @@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType { return this.equals(INVALID); } + public boolean isPartialReinvest() { + return this.equals(PARTIAL_REINVEST); + } + // TODO: do we really need this?!? public static Object[] integerValues() { return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 078d9aaf4..f3e7eba4f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -208,6 +208,7 @@ public final class DepositsApiConstants { public static final String onAccountClosureIdParamName = "onAccountClosureId"; public static final String transferDescriptionParamName = "transferDescription"; public static final String toSavingsAccountIdParamName = "toSavingsAccountId"; + public static final String reinvestAmountParamName = "reinvestAmount"; public static final String savingsAccounts = "savingsAccounts"; public static final String maturityInstructionIdParamName = "maturityInstructionId"; public static final String transferToSavingsIdParamName = "transferToSavingsId"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java index 57e025a06..3ed32ff1c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java @@ -797,6 +797,10 @@ public final class SavingsEnumerations { optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(), DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only"); break; + case PARTIAL_REINVEST: + optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(), + DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest"); + break; } return optionData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java index 5285eb94e..32de4e05b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java @@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, "); accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, "); - accountsSummary.append("sa.deposit_type_enum as depositType "); + accountsSummary.append("sa.deposit_type_enum as depositType, "); + // Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and + // this row's cycle within the chain, so a client's account list can collapse a renewed + // FD into one entry rather than listing every term as a separate account. + accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo "); accountsSummary.append("from m_savings_account sa "); accountsSummary.append("join m_savings_product as p on p.id = sa.product_id "); + accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id "); accountsSummary.append("join m_currency curr on curr.code = sa.currency_code "); accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); @@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); + // Null for a savings/current account and for an FD opened before this column existed; + // callers fall back to accountNo, so an FD that has never been renewed still reads as + // its own FD number. + final String fdAccountNumber = rs.getString("fdAccountNumber"); + final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo"); + return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus, - lastActiveTransactionDate); + lastActiveTransactionDate, fdAccountNumber, renewalCycleNo); } } @@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400); + // Current accounts have no deposit term, so no FD renewal chain to report. return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, - accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate); + accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate, + null, null); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index f3775ff8a..8d6e0c241 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -440,6 +440,9 @@ public class FixedDepositAccountsApiResource { } else if (is(commandParam, "prematureClose")) { final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build(); result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } else if (is(commandParam, "payment")) { + final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build(); + result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } else if (is(commandParam, "calculatePrematureAmount")) { final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson); final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper); @@ -453,7 +456,7 @@ public class FixedDepositAccountsApiResource { if (result == null) { throw new UnrecognizedQueryParamException("command", commandParam, new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest", - "postInterest", "close", "prematureClose", "calculatePrematureAmount" }); + "postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" }); } return this.toApiJsonSerializer.serialize(result); @@ -489,6 +492,8 @@ public class FixedDepositAccountsApiResource { DepositAccountData account = null; if (is(commandParam, "close")) { account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId); + } else if (is(commandParam, "payment")) { + account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId); } final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java index 995811ca1..3aa401060 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java @@ -366,6 +366,15 @@ public class DepositAccountDataValidator { "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); } + // Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is + // nothing sensible to pre-commit to years in advance as a standing instruction, so it is only + // ever chosen manually via the Payment action, never set here. + if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.partial.reinvest.not.allowed", + "Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() @@ -529,6 +538,15 @@ public class DepositAccountDataValidator { "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); } + // Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is + // nothing sensible to pre-commit to years in advance as a standing instruction, so it is only + // ever chosen manually via the Payment action, never set here. + if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.partial.reinvest.not.allowed", + "Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java index 03b69a1da..841776e50 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java @@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator { private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName)); + private static final Set DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>( + Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, + DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, + transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, + bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, + DepositsApiConstants.reinvestAmountParamName)); + @Autowired public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) { this.fromApiJsonHelper = fromApiJsonHelper; @@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator { if (onAccountClosureId != null) { final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (accountOnClosureType.isTransferToSavings()) { + if (accountOnClosureType.isPartialReinvest()) { + // Partial Re-Invest needs a kept-amount and a destination account that only the Payment + // command collects, and only its handler implements. Neither the FD nor the RD closure + // handler has a branch for it, so without this it would fall through to their terminal + // "withdraw the whole balance" case and silently pay the deposit out in cash. + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "partial.reinvest.not.allowed", + "Partial Re-Invest is only supported through the Payment action, not account closure"); + } else if (accountOnClosureType.isTransferToSavings()) { final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element); baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId) .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, @@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator { throwExceptionIfValidationWarningsExist(dataValidationErrors); } + /** + * Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is + * for Close/Premature Close, so this never changes either of their existing behavior. Unlike + * {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction -- + * Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own + * amount and destination-account rules. + */ + public void validatePayment(final JsonCommand command) { + final String json = command.json(); + + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(DepositAccountType.FIXED_DEPOSIT.resourceName()); + + final JsonElement element = command.parsedJson(); + + final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element); + baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull(); + + final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element); + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank() + .isOneOfTheseValues(DepositAccountOnClosureType.integerValues()); + + if (onAccountClosureId != null) { + final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + if (accountOnClosureType.isWithdarwDeposit()) { + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage( + "withdraw.deposit.not.allowed.for.payment", + "Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option."); + } + // Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same + // destination-account rule Transfer to Savings and Partial Reinvest already need. + if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest() + || accountOnClosureType.isReinvestPrincipal()) { + final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element); + baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId) + .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode()); + } + if (accountOnClosureType.isPartialReinvest()) { + final BigDecimal reinvestAmount = this.fromApiJsonHelper + .extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element); + baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull() + .positiveAmount(); + } + } + + // Validate all string payment detail fields for max length + final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element); + baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero(); + final Set paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName, + routingCodeParamName, receiptNumberParamName, bankNumberParamName)); + for (final String paymentDetailParameterName : paymentDetailParameters) { + final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element); + baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull() + .notExceedingLengthOf(50); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java index 6fde2049b..7fa657aa5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java @@ -87,6 +87,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException; import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException; import org.apache.fineract.portfolio.group.exception.GroupNotActiveException; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; @@ -97,9 +98,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; +import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException; import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException; import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -123,6 +126,7 @@ public class DepositAccountAssembler { private final ExternalIdFactory externalIdFactory; private final SavingsAccountAssembler savingsAccountAssembler; + private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; @Autowired public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, @@ -134,7 +138,8 @@ public class DepositAccountAssembler { final RecurringDepositProductRepository recurringDepositProductRepository, final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context, final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory, - final SavingsAccountAssembler savingsAccountAssembler) { + final SavingsAccountAssembler savingsAccountAssembler, + final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) { this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; this.clientRepository = clientRepository; @@ -151,6 +156,7 @@ public class DepositAccountAssembler { this.paymentDetailAssembler = paymentDetailAssembler; this.externalIdFactory = externalIdFactory; this.savingsAccountAssembler = savingsAccountAssembler; + this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService; } /** @@ -322,6 +328,28 @@ public class DepositAccountAssembler { accountChart = DepositAccountInterestRateChart.from(productChart); } + // A new FD priced from its product's own chart never carries a senior-citizen premium -- that + // concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a + // specific chart, price the account from that master's currently active version instead (the same + // one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows + // its premium from day one rather than only after its first renewal. RD is unaffected: this only + // runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate + // version exists for the product's FD type, in which case the product's own chart above stands. + if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName) + && product instanceof FixedDepositProduct) { + final String fdType = ((FixedDepositProduct) product).getFdType(); + if (fdType != null) { + final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService + .retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null); + if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) { + final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates); + final Collection baseChartSlabs = DepositAccountDomainServiceJpa + .baseChartSlabsOf(activeBaseRates, product.currency().getCode()); + accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs); + } + } + } + boolean withHoldTax = product.withHoldTax(); if (command.parameterExists(withHoldTaxParamName)) { withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java index aff6abadd..82e640fb2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java @@ -64,4 +64,7 @@ public interface DepositAccountDomainService { Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command, Map changes); + + Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command, + Map changes); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 05f48a177..62d049e95 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.savings.domain; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName; @@ -26,6 +27,9 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository; import org.apache.fineract.portfolio.calendar.domain.CalendarType; import org.apache.fineract.portfolio.calendar.service.CalendarUtils; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsApiConstants; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; +import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; +import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; @@ -80,6 +90,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; private final CalendarInstanceRepository calendarInstanceRepository; private final ExternalIdFactory externalIdFactory; + private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository; + private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository; + private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; @Transactional @Override @@ -220,8 +233,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); if (onClosureType.isReinvest()) { + // Captured before the withdrawal below empties the account, so the renewal history can + // record what this term actually matured at. + final BigDecimal maturedBalance = account.getAccountBalance(); ExternalId externalId = this.externalIdFactory.create(); - FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId); + FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId); this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); @@ -230,9 +246,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // Renewal is an internal movement into the new deposit, not cash paid out -- post it // against the transfer-suspense GL rather than crediting cash/vault. final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), - paymentDetail, false, isRegularTransaction, isInternalTransferLeg); + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail, + false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); + // Reached when a user closes the account with a re-invest instruction, rather than the + // maturity job acting on a standing instruction. + recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); } else if (onClosureType.isTransferToSavings()) { final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); @@ -283,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvest()) { - BigDecimal reInvestAmount; - if (onClosureType.isReinvestPrincipal()) { - reInvestAmount = account.getDepositAmount(); - } else { - reInvestAmount = account.getAccountBalance(); - } + if (onClosureType.isReinvestPrincipalAndInterest()) { + // Renews on the SAME account, so the customer keeps one account number however many times + // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new + // term's principal, so there is no withdrawal, no deposit and no journal entry for the + // rollover itself -- which also removes the transfer-suspense round trip the old + // close-and-reopen flow needed. + final BigDecimal closingPrincipal = account.getDepositAmount(); + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null + : maturedBalance.subtract(closingPrincipal); + + // The active CBS base interest-rate version for this FD type: the same rate card the Base + // Interest Rates screen shows and the account-opening wizard prices from. Used for both the + // new term's slabs and its senior-citizen premium, so screen and account agree. + final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null + : this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst() + .orElse(null); + + final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen() + ? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency()) + : null; + + // Captured before renewInPlace overwrites the account's chart, so the closing term's full + // rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost + // -- renewInPlace mutates the same chart row every time, it does not create a new one. + final String closingChartName = account.currentChartName(); + final List closingChartSlabs = account.currentChartSlabFields(); + // Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new + // term's -- needed only if this is the closing term's first-ever history row (see + // recordInPlaceRenewal), so that row records what this term actually ran at instead of blank. + final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate(); + final Integer closingTenure = account.depositPeriod(); + final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); + + account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingsAccountRepository.save(account); + + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, + closingTenure, closingTenureFrequencyEnum); + changes.put("renewedInPlace", Boolean.TRUE); + changes.put("renewalCycleNo", account.renewalCycleNo()); + + } else if (onClosureType.isReinvest()) { + // "Re-invest principal only" still closes and reopens, because only the principal carries + // forward and the interest has to leave the deposit -- which needs a payout destination that + // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal reInvestAmount = account.getDepositAmount(); ExternalId externalId = this.externalIdFactory.create(); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); @@ -311,14 +376,14 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // postJournalEntries() call at the end of this method posts it a second time. updateAlreadyPostedTransactions(existingTransactionIds, account); - if (onClosureType.isReinvestPrincipalAndInterest()) { - account.updateClosedStatus(); - account.updateOnAccountClosureStatus(onClosureType); - } changes.put("reinvestedDepositId", reinvestedDeposit.getId()); reinvestedDeposit.approveAndActivateApplication(closedDate, user); this.savingsAccountRepository.save(reinvestedDeposit); + // Reached from the maturity job processing the account's standing maturity instruction. + recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); @@ -461,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi } } + /** + * Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium -- + * against its {@link FixedDepositRenewalHistory} row. + *

+ * Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the + * closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive. + */ + private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName, + final List snapshots, final String currencyCode) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) { + final InterestRateChartSlabFields fields = snapshot.getFields(); + if (fields.annualInterestRate() == null || fields.fromPeriod() == null) { + continue; + } + this.fixedDepositRenewalChartSlabHistoryRepository + .save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(), + fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(), + fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate())); + } + } + + /** + * Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private + * and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a + * new FD account is first priced from this master, instead of duplicating the logic. + */ + static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) { + // name and from_date are NOT NULL on the embeddable. A base version missing either would abort the + // whole renewal (or account creation) with a constraint violation, so treat it as "no identity to + // apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and + // the slabs -- the part that actually prices the deposit -- still get applied. + if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) { + return null; + } + final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount + return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(), + baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount); + } + + /** + * Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen + * premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live. + *

+ * The base master bands purely by tenure, so amount ranges are left unset -- which + * {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no + * from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure. + */ + static Collection baseChartSlabsOf(final FdInterestRateEffectiveData baseRates, + final String currencyCode) { + if (baseRates == null || baseRates.getSlabs() == null) { + return Collections.emptyList(); + } + final List snapshots = new ArrayList<>(); + for (final FdInterestRateSlabData slab : baseRates.getSlabs()) { + if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) { + continue; + } + final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(), + SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null, + slab.getAnnualInterestRate(), currencyCode); + snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate())); + } + return snapshots; + } + + /** + * The senior-citizen premium from the active base rate card for a given tenure. + *

+ * The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years + * still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or + * months/years -- because only those are exact; a days-based card is therefore still never matched against a + * months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the + * wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the + * account's existing rate untouched rather than silently dropping the depositor's premium. + */ + private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure, + final Integer tenurePeriodTypeEnum) { + if (baseRates == null || baseRates.getSlabs() == null || tenure == null) { + return null; + } + final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum); + if (tenureInBaseUnit == null) { + return null; + } + for (final FdInterestRateSlabData slab : baseRates.getSlabs()) { + if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) { + continue; + } + final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum()); + if (slabFrom == null || tenureInBaseUnit < slabFrom) { + continue; + } + if (slab.getToPeriod() != null) { + final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum()); + if (slabTo == null || tenureInBaseUnit > slabTo) { + continue; + } + } + final BigDecimal rate = slab.getSeniorCitizenRate(); + if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { + return rate; + } + } + return null; + } + + /** + * {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so + * two periods written in different but related units can be compared without losing precision. Null for an unknown + * unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3). + */ + private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) { + if (value == null || periodTypeEnum == null) { + return null; + } + return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) { + case DAYS, MONTHS -> value; + case WEEKS -> value * 7; + case YEARS -> value * 12; + default -> null; + }; + } + + /** + * Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those + * two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never + * matched against a months-based tenure rather than guessing and landing the deposit in the wrong band. + */ + private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) { + if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) { + return false; + } + return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum); + } + + private boolean isDayFamily(final Integer periodTypeEnum) { + final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum); + return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS; + } + + /** + * Records a renewal that happened on the SAME account, where there is no second account row to describe the new term. + *

+ * Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number, + * principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the + * account, where they no longer exist. Every row for one FD shares that FD's single account id and account number; + * they are told apart by renewal_no, which is what makes this a one-to-many history of one account. + * + * @param closingChartName + * the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if + * unavailable + * @param closingChartSlabs + * the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived + * against the closing term's row so its whole rate card remains queryable, not only the single effective + * rate {@link FixedDepositRenewalHistory} records. + * @param closingInterestRate + * the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it -- + * needed only when this term has no existing row yet (see below), since the account itself no longer + * carries it once renewInPlace has run. + * @param closingTenure + * the closing term's own deposit period, captured the same way and needed for the same reason. + * @param closingTenureFrequencyEnum + * the closing term's own deposit period frequency, captured the same way and needed for the same reason. + */ + private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal, + final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount, + final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName, + final List closingChartSlabs, final BigDecimal closingInterestRate, + final Integer closingTenure, final Integer closingTenureFrequencyEnum) { + + // The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old + // close-and-reopen model before renewing in place: its rows are keyed on the original account's + // number, so keying new rows on this account's number would split the history in two and re-create + // a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's + // own number when there is no chain, which is what the read side resolves to as well. + final String fdAccountNumber = account.fdAccountNumber(); + final int newCycleNo = account.renewalCycleNo(); + final Long createdById = user == null ? null : user.getId(); + + final List existingRows = this.fixedDepositRenewalHistoryRepository + .findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber); + FixedDepositRenewalHistory closingRow = existingRows.stream() + .filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null); + + // The term that just closed has no row when this FD renews for the first time since the history + // table existed. Written now, from the figures captured before the rollover, so the history + // always begins with the term the customer originally opened -- rate and tenure included, rather + // than left blank, since the caller captured them before renewInPlace overwrote the account with + // the new term's own values. + if (closingRow == null) { + closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber, + account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, + closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, + closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + } + closingRow.recordOutcome(closingInterestEarned, maturedBalance); + closingRow.markMatured(); + this.fixedDepositRenewalHistoryRepository.save(closingRow); + + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); + + // The new term's own interest/maturity stay unset until it closes in turn. + this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), + account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, + account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(), + account.depositPeriodFrequency(), renewalType, renewedOn, createdById)); + } + + /** + * Records the renewal that started {@code newTerm}, and closes off the previous term's history row. + *

+ * Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event + * itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not + * recoverable from the account rows alone. This writes it down explicitly. + *

+ * The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only + * gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no + * original. + */ + private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm, + final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType, + final AppUser user) { + + final String fdAccountNumber = previousTerm.fdAccountNumber(); + final Long originalAccountId = this.fixedDepositRenewalHistoryRepository + .findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst() + .map(FixedDepositRenewalHistory::getOriginalSavingsAccountId) + .orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null); + + FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository + .findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null); + + // Backfill the opening term when the chain started before this table existed, so the history + // always begins with the account the customer actually opened. + if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) { + previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber, + originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(), + previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(), + previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(), + previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(), + user == null ? null : user.getId())); + } + + // What the closing term actually earned. maturityAmount is passed in rather than read off the + // account because the caller has already withdrawn the balance out of it by this point, which + // would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own + // term's outcome, so a reader never has to look at the next row to find out how one term did. + final BigDecimal previousPrincipal = previousTerm.getDepositAmount(); + final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null + : maturityAmount.subtract(previousPrincipal); + + if (previousRow != null) { + previousRow.recordOutcome(interestEarned, maturityAmount); + previousRow.markMatured(); + this.fixedDepositRenewalHistoryRepository.save(previousRow); + } + + // The new term's own interest/maturity stay unset until it closes in turn. + this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, + originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(), + previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount, + newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(), + newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId())); + } + @Transactional @Override public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user, @@ -519,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi return savingsTransactionId; } + /** + * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close + * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support + * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest + * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through + * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what + * the existing manual Close command still uses today. + *

+ * Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- + * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as + * before. + */ + @Transactional + @Override + public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user, + final JsonCommand command, final Map changes) { + + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + + boolean isRegularTransaction = false; + boolean isAccountTransfer = false; + final boolean isPreMatureClosure = false; + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); + + final MathContext mc = MathContext.DECIMAL64; + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + Long savingsTransactionId = null; + + // account.isMatured() only reflects whether the overnight scheduler has already flipped this + // account's status -- not whether the customer actually showed up before or after the maturity + // date. A customer walking in ON the maturity date (or any day after) is not premature just + // because that job hasn't run yet today, so the real test is the date, not the status flag. + if (!account.isMatured()) { + if (!DateUtils.isBefore(closedDate, account.maturityDate())) { + // Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would: + // flip to Matured and post final interest at the full contracted rate, no penalty. + account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } else { + // The three closing closure types re-check this themselves inside prematureClosure() + // below, but the two reinvest branches never call it (they keep the account open on the + // same term), so it is enforced once, up front, for every genuinely-premature Payment -- + // a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via + // this action either. + if (account.isLockedOnDate(closedDate)) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed", + "This account is still within its lock-in period and cannot be processed before it elapses.", closedDate); + } + // Same penalty-aware interest calculation prematureClosure() already relies on, so a + // genuinely early Payment settles interest exactly as a Premature Close would. + account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } + } else { + account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } + + // Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads + // getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set + // before branching. renewInPlace() clears it again for the branches that keep the account open. + account.setClosedOnDate(closedDate); + + // Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now + // correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way. + final boolean alreadyMatured = account.isMatured(); + + final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); + final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { + // These two branches keep the account open, so they never reach close()/prematureClosure() and + // would otherwise accept any date at all -- including one behind already-posted interest, which + // the renewal would then turn into a pivot that reverses and re-posts it. + account.validateRenewalDate(closedDate); + + final BigDecimal closingPrincipal = account.getDepositAmount(); + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null + : maturedBalance.subtract(closingPrincipal); + + final BigDecimal renewedAmount; + if (onClosureType.isPartialReinvest()) { + renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName); + if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0 + || renewedAmount.compareTo(maturedBalance) >= 0) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", + "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); + } + } else { + renewedAmount = maturedBalance; + } + + final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null + : this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst() + .orElse(null); + final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen() + ? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency()) + : null; + + final String closingChartName = account.currentChartName(); + final List closingChartSlabs = account.currentChartSlabFields(); + // Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new + // term's -- needed only if this is the closing term's first-ever history row (see + // recordInPlaceRenewal), so that row records what this term actually ran at instead of blank. + final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate(); + final Integer closingTenure = account.depositPeriod(); + final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); + + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingsAccountRepository.save(account); + + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate, + closingTenure, closingTenureFrequencyEnum); + changes.put("renewedInPlace", Boolean.TRUE); + changes.put("renewalCycleNo", account.renewalCycleNo()); + + if (onClosureType.isPartialReinvest()) { + // The kept portion never left the account -- renewInPlace() just relabels it as the new + // term's principal -- so only the remainder is an actual money movement, posted the same + // way the Transfer to Savings branch below posts its full-balance transfer. + final BigDecimal remainder = maturedBalance.subtract(renewedAmount); + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, + PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, + AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + + } else if (onClosureType.isReinvestPrincipal()) { + // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- + // the interest has to leave the deposit, which needs a payout destination an in-place renewal + // has nowhere to send. + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal reInvestAmount = account.getDepositAmount(); + final ExternalId externalId = this.externalIdFactory.create(); + + final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); + this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); + reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); + autoGenerateAccountNumber(reinvestedDeposit); + final boolean isInternalTransferLeg = true; + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, + false, isRegularTransaction, isInternalTransferLeg); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + changes.put("reinvestedDepositId", reinvestedDeposit.getId()); + reinvestedDeposit.approveAndActivateApplication(closedDate, user); + this.savingsAccountRepository.save(reinvestedDeposit); + + recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); + + // Only the principal moved into the new deposit -- the interest earned on this term is still + // sitting in this account's balance. Pay it out to the chosen savings account now, before + // closing, rather than stranding it here with nowhere left to go. + final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); + if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, + null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + + } else if (onClosureType.isTransferToSavings()) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(), + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, + null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + + } else { + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), + paymentDetail, false, isRegularTransaction); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + } + + this.savingsAccountRepository.save(account); + postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer); + + return savingsTransactionId; + } + @Transactional @Override public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 95cff6e3f..47b02dcf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; @@ -276,7 +277,9 @@ public class FixedDepositAccount extends SavingsAccount { public LocalDate calculateMaturityDate() { - final LocalDate startDate = accountSubmittedOrActivationDate(); + // The CURRENT term's start, not the account's activation date -- on a renewed FD these differ, + // and measuring the term from activation would put maturity in the past. + final LocalDate startDate = depositStartDate(); LocalDate maturityDate = null; final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) { @@ -312,13 +315,19 @@ public class FixedDepositAccount extends SavingsAccount { .fromInt(this.interestCalculationDaysInYearType); List postedAsOnTransactionDates = getManualPostingDates(); + // Scoped to the current term, so a renewed FD's projection covers only the term now running -- + // matching what postInterest() will actually post -- rather than replaying the whole account. final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( - accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, - postedAsOnTransactionDates); + depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates); final List allPostingPeriods = new ArrayList<>(); - Money periodStartingBalance = Money.zero(currency); + // On a first term the opening deposit falls inside the first posting period, so the period can + // start from zero and pick the balance up from that transaction. On a renewed term that deposit + // is BEFORE the period range and would be missed, leaving the term to earn interest on nothing, + // so the term's own principal seeds the opening balance instead. + Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount()) + : Money.zero(currency); final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure); @@ -686,6 +695,60 @@ public class FixedDepositAccount extends SavingsAccount { return this.accountTermAndPreClosure.maturityAmount(); } + /** + * The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to + * this account's own number when it has never been renewed, so callers always get a usable FD number. + */ + public String fdAccountNumber() { + final String carried = this.accountTermAndPreClosure.getFdAccountNumber(); + return carried != null ? carried : getAccountNumber(); + } + + /** 0 for the originally opened term, 1 for the first renewal, and so on. */ + public Integer renewalCycleNo() { + return this.accountTermAndPreClosure.getRenewalCycleNo(); + } + + public Integer depositPeriod() { + return this.accountTermAndPreClosure.depositPeriod(); + } + + public Integer depositPeriodFrequency() { + return this.accountTermAndPreClosure.depositPeriodFrequency(); + } + + public Long reinvestedFromSavingsId() { + return this.accountTermAndPreClosure.getReinvestedFromSavingsId(); + } + + /** + * The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's + * chart identity can be archived rather than lost. + */ + public String currentChartName() { + return this.chart == null ? null : this.chart.chartFields().getName(); + } + + /** + * The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached + * from the chart's own persistence lifecycle. + *

+ * Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full + * rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal + * history records -- rather than being silently deleted when the new term's slabs replace it. + */ + public List currentChartSlabFields() { + return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots(); + } + + /** + * This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when + * the product is not a Fixed Deposit product or has no type set. + */ + public String fdType() { + return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null; + } + private Money totalInterestPosted() { Money interestPostedToDate = Money.zero(this.currency); for (final SavingsAccountTransaction transaction : this.transactions) { @@ -715,11 +778,26 @@ public class FixedDepositAccount extends SavingsAccount { return actualChanges; } + /** + * The start of the term currently running, which is what the deposit's tenure and rate must be measured from. + *

+ * For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances + * startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS + * term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the + * renewal rather than from the original activation. + */ private LocalDate depositStartDate() { - // TODO: Support to add deposit start date which can be a date after - // account activation date. - final LocalDate depositStartDate = accountSubmittedOrActivationDate(); - return depositStartDate; + final LocalDate currentTermStartDate = getStartInterestCalculationDate(); + return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate(); + } + + /** + * True once this FD has been renewed at least once, i.e. the term now running started later than the account did. + * Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term. + */ + private boolean isRenewedTerm() { + final LocalDate termStart = getStartInterestCalculationDate(); + return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate()); } private LocalDate interestCalculatedUpto() { @@ -814,20 +892,129 @@ public class FixedDepositAccount extends SavingsAccount { return this.accountTermAndPreClosure.getTransferToSavingsAccountId(); } + /** + * Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it + * renews. No new account is opened and no money moves: the balance simply carries into the next term. + *

+ * What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines + * interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the + * posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes + * {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it + * stands today, so a renewal is priced on current terms rather than the ones frozen at account opening. + *

+ * The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal + * history so each term's own figures remain reportable. + * + * @param renewedAmount + * what carries into the new term: the full maturity amount, or the principal only + * @param renewedOn + * the renewal date, which becomes the new term's start + * @param currentSeniorCitizenRate + * the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to + * keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the + * renewal never turns that flag on or off. + * @param baseChartFields + * identity of the CBS base rate version being applied, or null to keep the existing chart's + * @param baseChartSlabs + * the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the + * rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls + * back to the product's own chart, so a product with no base rate version still renews on something. + */ + public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate, + final InterestRateChartFields baseChartFields, final Collection baseChartSlabs) { + + final SavingsProduct product = this.product; + + // Confines interest posting to the new term. Set before recomputing maturity, since maturity is + // measured from the current term's start. + setStartInterestCalculationDate(renewedOn); + + this.accountTermAndPreClosure.updateDepositAmount(renewedAmount); + this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1); + // Pin the chain number now that this account has renewed at least once, so a non-zero cycle number + // is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number + // if there is one and to this account's own otherwise, which is the same value every reader derives. + this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber()); + + // Re-price off the CURRENT product, exactly as opening a fresh deposit today would. + this.nominalAnnualInterestRate = product.nominalAnnualInterestRate(); + this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue(); + this.interestPostingPeriodType = product.interestPostingPeriodType().getValue(); + this.interestCalculationType = product.interestCalculationType().getValue(); + this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue(); + this.withHoldTax = product.withHoldTax(); + + // Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy + // close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would + // carry the lock-in window computed at the original activation -- long expired -- so a product with + // a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only. + final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType(); + this.lockinPeriodFrequency = product.lockinPeriodFrequency(); + this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null + : renewedLockinPeriodFrequencyType.getValue(); + this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null + : calculateDateAccountIsLockedUntil(renewedOn); + + // The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on + // the Base Interest Rates screen, per FD type, with its own active version. That master is what + // the bank publishes and what the account-opening wizard already shows, so pricing a renewal off + // anything else would put the deposit on rates nobody chose. + if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) { + this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs); + } else if (this.chart != null) { + // No active base version for this FD type: fall back to the product's own chart so the + // renewal still gets current product rates rather than the ones frozen at opening. + // activeChart, not applicableChart -- the latter treats a chart with no end date as valid only + // up to today's business date, so a renewal dated in the future would find nothing. + final InterestRateChart productChart = product instanceof FixedDepositProduct + ? ((FixedDepositProduct) product).activeChart(renewedOn) + : null; + if (productChart != null) { + this.chart.refreshFrom(productChart); + } + } + + // The senior-citizen premium is stored per account and takes priority over the chart in + // getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a + // senior-citizen deposit at the rate captured when the account was opened. Only overwritten when + // the caller found a currently published rate -- otherwise the existing one stands, so a missing + // rate version can never silently drop the depositor's premium. + if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) { + setSeniorCitizenInterestRate(currentSeniorCitizenRate); + } + + // The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before + // this ran, so the new term has to put it back. + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + setClosedOnDate(null); + } + public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) { final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount); final SavingsProduct product = this.product; - final InterestRateChart productChart = product.applicableChart(getClosedOnDate()); + // Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's + // business date, and the most recently effective chart wins when more than one covers the date. + // Falls back to applicableChart so a product whose charts have no usable from-date still resolves + // as it did before. + InterestRateChart productChart = product instanceof FixedDepositProduct + ? ((FixedDepositProduct) product).activeChart(getClosedOnDate()) + : null; + if (productChart == null) { + productChart = product.applicableChart(getClosedOnDate()); + } final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart); final AccountType accountType = AccountType.fromInt(this.accountType); - final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType); - final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(this.interestCompoundingPeriodType); - final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); - final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType - .fromInt(this.interestCalculationDaysInYearType); + // Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's + // frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a + // renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting + // was in force when the ORIGINAL account was opened, silently ignoring every product edit made + // since (e.g. Daily->Quarterly compounding, or a lock-in added later). + final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType(); + final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType(); + final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType(); + final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType(); final BigDecimal minRequiredOpeningBalance = null; // Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses // for a brand-new account -- so a product with no chart slab covering this tenure/amount @@ -836,11 +1023,11 @@ public class FixedDepositAccount extends SavingsAccount { // original account was opened) whenever a matching slab exists. final BigDecimal interestRate = product.nominalAnnualInterestRate(); final Set savingsAccountCharges = null; - final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); - final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; + final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType(); + final Integer lockinPeriodFrequency = product.lockinPeriodFrequency(); final boolean withdrawalFeeApplicableForTransfer = false; final String accountNumber = null; - final boolean withHoldTax = this.withHoldTax; + final boolean withHoldTax = product.withHoldTax(); final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType, postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, @@ -859,6 +1046,21 @@ public class FixedDepositAccount extends SavingsAccount { // each other instead of the link disappearing the moment the old one closes. newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId()); + // Carry the ORIGINAL account's number forward across every renewal, so the customer keeps + // seeing one recognisable FD number even though each term is its own account/ledger row. + final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null + ? this.accountTermAndPreClosure.getFdAccountNumber() + : this.getAccountNumber(); + newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber); + + // Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048". + newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1); + + // Without this the renewed deposit has no maturity instruction, so it would mature once more and + // then sit idle rather than rolling over again as the customer asked. Done here rather than in + // the shared copy(), which Recurring Deposit renewal also uses. + newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure); + return reInvestedAccount; } @@ -918,6 +1120,52 @@ public class FixedDepositAccount extends SavingsAccount { return SavingsAccountStatusType.MATURED.getValue().equals(this.status); } + /** + * Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the + * Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going + * through {@link #prematureClosure}, since those branches keep the account open on the same term instead of + * closing it. + */ + public boolean isLockedOnDate(final LocalDate transactionDate) { + return isAccountLocked(transactionDate); + } + + /** + * The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment + * that renews the deposit instead of closing it and so never reaches either of those methods. + *

+ * Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three + * matter more here than they do on a closure, because a renewal turns this date into the new term's interest + * pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting + * periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent. + */ + public void validateRenewalDate(final LocalDate renewedOn) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction); + + if (DateUtils.isAfterBusinessDate(renewedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("cannot.be.a.future.date"); + } + if (DateUtils.isBefore(renewedOn, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("must.be.after.activation.date"); + } + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (!savingsAccountTransactions.isEmpty()) { + final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (lastTransaction.isAfter(renewedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("must.be.after.last.transaction.date"); + } + } + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + public void setClosedOnDate(final LocalDate closedOnDate) { this.closedOnDate = closedOnDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java new file mode 100644 index 000000000..bd72dd9e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java @@ -0,0 +1,99 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.apache.fineract.portfolio.savings.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's + * {@link FixedDepositRenewalHistory} row. + *

+ * {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in + * place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not + * the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during + * term 2", not just "what rate applied". + */ +@Entity +@Table(name = "m_fd_renewal_chart_slab_history") +@Getter +@NoArgsConstructor +public class FixedDepositRenewalChartSlabHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "fd_renewal_history_id", nullable = false) + private Long fdRenewalHistoryId; + + @Column(name = "chart_name", length = 100) + private String chartName; + + @Column(name = "description", length = 200) + private String description; + + @Column(name = "period_type_enum") + private Integer periodTypeEnum; + + @Column(name = "from_period") + private Integer fromPeriod; + + @Column(name = "to_period") + private Integer toPeriod; + + @Column(name = "amount_range_from", scale = 6, precision = 19) + private BigDecimal amountRangeFrom; + + @Column(name = "amount_range_to", scale = 6, precision = 19) + private BigDecimal amountRangeTo; + + @Column(name = "annual_interest_rate", scale = 6, precision = 19) + private BigDecimal annualInterestRate; + + @Column(name = "currency_code", length = 3) + private String currencyCode; + + /** + * The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the + * CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was + * priced off the product's own chart (which has no senior-citizen concept) or predates this column. + */ + @Column(name = "senior_citizen_rate", scale = 6, precision = 19) + private BigDecimal seniorCitizenRate; + + public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName, + final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod, + final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate, + final String currencyCode, final BigDecimal seniorCitizenRate) { + final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory(); + slab.fdRenewalHistoryId = fdRenewalHistoryId; + slab.chartName = chartName; + slab.description = description; + slab.periodTypeEnum = periodTypeEnum; + slab.fromPeriod = fromPeriod; + slab.toPeriod = toPeriod; + slab.amountRangeFrom = amountRangeFrom; + slab.amountRangeTo = amountRangeTo; + slab.annualInterestRate = annualInterestRate; + slab.currencyCode = currencyCode; + slab.seniorCitizenRate = seniorCitizenRate; + return slab; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java new file mode 100644 index 000000000..8669f3819 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository { + + /** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */ + List findByFdRenewalHistoryId(Long fdRenewalHistoryId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java new file mode 100644 index 000000000..e63bb9817 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java @@ -0,0 +1,158 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.apache.fineract.portfolio.savings.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * One row per term of one logical Fixed Deposit, recording the renewal event that started it. + *

+ * Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable + * ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is + * what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is + * what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried + * forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a + * user, and by whom. + */ +@Entity +@Table(name = "m_fd_renewal_history") +@Getter +@NoArgsConstructor +public class FixedDepositRenewalHistory { + + /** Renewal ran automatically when the maturity job processed the account's maturity instruction. */ + public static final String RENEWAL_TYPE_AUTO = "AUTO"; + /** A user closed the account with a re-invest instruction. */ + public static final String RENEWAL_TYPE_MANUAL = "MANUAL"; + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_MATURED = "MATURED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** The stable customer-facing FD number, shared by every term in the chain. */ + @Column(name = "fd_account_number", length = 20, nullable = false) + private String fdAccountNumber; + + /** The account the customer originally opened, i.e. the anchor of the chain. */ + @Column(name = "original_savings_account_id", nullable = false) + private Long originalSavingsAccountId; + + /** The deposit instance this term runs on. */ + @Column(name = "savings_account_id", nullable = false) + private Long savingsAccountId; + + /** The term this one was renewed from; null on the chain's first row. */ + @Column(name = "previous_savings_account_id") + private Long previousSavingsAccountId; + + @Column(name = "previous_renewal_id") + private Long previousRenewalId; + + /** 0 for the originally opened term, 1 for the first renewal, and so on. */ + @Column(name = "renewal_no", nullable = false) + private Integer renewalNo; + + @Column(name = "previous_principal", scale = 6, precision = 19) + private BigDecimal previousPrincipal; + + @Column(name = "interest_earned", scale = 6, precision = 19) + private BigDecimal interestEarned; + + @Column(name = "maturity_amount", scale = 6, precision = 19) + private BigDecimal maturityAmount; + + /** What actually carried into this term: the full maturity amount, or principal only. */ + @Column(name = "renewed_amount", scale = 6, precision = 19) + private BigDecimal renewedAmount; + + @Column(name = "interest_rate", scale = 6, precision = 19) + private BigDecimal interestRate; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "maturity_date") + private LocalDate maturityDate; + + @Column(name = "tenure") + private Integer tenure; + + @Column(name = "tenure_frequency_enum") + private Integer tenureFrequencyEnum; + + @Column(name = "renewal_type", length = 10) + private String renewalType; + + @Column(name = "renewal_status", length = 20) + private String renewalStatus; + + @Column(name = "renewed_on") + private LocalDate renewedOn; + + @Column(name = "created_by_id") + private Long createdById; + + public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId, + final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo, + final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount, + final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate, + final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn, + final Long createdById) { + final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory(); + history.fdAccountNumber = fdAccountNumber; + history.originalSavingsAccountId = originalSavingsAccountId; + history.savingsAccountId = savingsAccountId; + history.previousSavingsAccountId = previousSavingsAccountId; + history.previousRenewalId = previousRenewalId; + history.renewalNo = renewalNo; + history.previousPrincipal = previousPrincipal; + history.interestEarned = interestEarned; + history.maturityAmount = maturityAmount; + history.renewedAmount = renewedAmount; + history.interestRate = interestRate; + history.startDate = startDate; + history.maturityDate = maturityDate; + history.tenure = tenure; + history.tenureFrequencyEnum = tenureFrequencyEnum; + history.renewalType = renewalType; + history.renewalStatus = STATUS_ACTIVE; + history.renewedOn = renewedOn; + history.createdById = createdById; + return history; + } + + /** Called on the previous term's row once its successor takes over. */ + public void markMatured() { + this.renewalStatus = STATUS_MATURED; + } + + /** + * Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's + * own row so each term reports its own outcome rather than that of its predecessor. + */ + public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) { + this.interestEarned = interestEarned; + this.maturityAmount = maturityAmount; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java new file mode 100644 index 000000000..cb9579ee5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FixedDepositRenewalHistoryRepository extends JpaRepository { + + /** One FD's whole history, oldest term first. */ + List findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber); + + /** + * An account's terms, newest first. + *

+ * Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at + * that one account, so callers wanting the term currently running take the first element. Empty for an account that + * pre-dates this table or was never renewed. + */ + List findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java new file mode 100644 index 000000000..6fd3ea504 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.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.savings.service.DepositAccountWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT") +public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler { + + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + + @Autowired + public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) { + this.depositAccountWritePlatformService = depositAccountWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java index bdd07b3af..87cf93a38 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java @@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D .append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ") .append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ") .append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ") + .append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ") .append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ") @@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom"); final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo"); final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate"); + final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate"); // currency Slabs final String currencyCode = rs.getString("currencyCode"); @@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D currencyNameCode); return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom, - amountRangeTo, annualInterestRate, currency); + amountRangeTo, annualInterestRate, currency, seniorCitizenRate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java index 21841f4bd..d6d943485 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java @@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService { DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId); + /** + * As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries + * PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it. + */ + DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId); + DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId); Collection retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 130b1fb60..421ed87ad 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -367,6 +367,121 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead if (fromId != null || intoId != null) { result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo); } + + // The number to actually show as "the FD account number" -- the original account's account_no + // if this account has been through a renewal, otherwise this account's own account_no. + final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure " + + "where savings_account_id = ?"; + final java.util.List> termRows = this.jdbcTemplate.queryForList(termSql, accountId); + final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number"); + final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no"); + final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo(); + result.setFdAccountNumber(rootFdAccountNumber); + result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue()); + result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber)); + } + + /** + * Every term of one logical FD, oldest first. + *

+ * Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it + * matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by + * joining account rows, which cannot express any of that. The account row is joined in for its number and live + * status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as + * simply a non-empty list. + */ + private java.util.List> retrieveRenewalHistory(final String fdAccountNumber) { + // Every row states its own term's figures, so this is a straight read: interest_earned and + // maturity_amount are filled in when that term closes, and stay null on the term still running, + // which the caller renders as its projected maturity instead. + final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, " + + "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, " + + "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, " + + "h.tenure_frequency_enum as tenureFrequencyEnum, " + + "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, " + + "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, " + + "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h " + + "join m_savings_account sa on sa.id = h.savings_account_id " + + "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id " + + "where h.fd_account_number = ? " + "order by h.renewal_no asc"; + final java.util.List> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber); + // Any row here is a renewal event that was actually recorded, so even one is worth returning -- + // discarding a lone row used to hide a genuine renewal (and its interest figure) behind the + // account-derived fallback, which then returned nothing at all for it. + if (!rows.isEmpty()) { + attachArchivedChartSlabs(rows); + return rows; + } + // Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to + // reconstructing what the account rows alone can tell us. The per-term interest/renewal-type + // columns are simply absent for those, which the UI renders as blanks rather than wrong values. + return retrieveRenewalHistoryFromAccounts(fdAccountNumber); + } + + /** + * Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row. + *

+ * A term's chart is only archived when it closes via an in-place renewal (see + * {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before + * that archiving existed simply get an empty list here -- callers should fall back to the account's live + * accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since + * this runs once per FD account view. + */ + private void attachArchivedChartSlabs(final java.util.List> rows) { + final java.util.List historyIds = new java.util.ArrayList<>(); + for (final java.util.Map row : rows) { + final Object id = row.get("historyId"); + if (id != null) { + historyIds.add(((Number) id).longValue()); + } + } + if (historyIds.isEmpty()) { + return; + } + final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?")); + final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, " + + "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, " + + "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate " + + "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") " + + "order by from_period asc"; + final java.util.List> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray()); + + final java.util.Map>> slabsByHistoryId = new java.util.HashMap<>(); + for (final java.util.Map slabRow : slabRows) { + final Long historyId = ((Number) slabRow.remove("historyId")).longValue(); + slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow); + } + + for (final java.util.Map row : rows) { + final Object id = row.get("historyId"); + final Long historyId = id == null ? null : ((Number) id).longValue(); + row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList())); + } + } + + private java.util.List> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) { + // Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has + // to know which path produced the rows. What the account rows genuinely cannot tell us -- each + // term's interest earned, and whether the rollover was automatic or done by staff -- is returned as + // an explicit null rather than omitted, so those render as blanks instead of the previous behaviour + // where a missing renewalStatus made every term, including the running one, look matured. + final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, " + + "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, " + + "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, " + + "t.maturity_date as maturityDate, t.deposit_period as tenure, " + + "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, " + + "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, " + + "sa.status_enum as statusEnum, null as interestEarned, " + + "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, " + + "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount " + + "from m_savings_account sa " + + "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id " + + "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc"; + final java.util.List> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber, + fdAccountNumber); + // A single row here means this FD simply never renewed -- one account, one term -- so there is no + // history to show. Unlike the primary path, where even one recorded row is a real renewal event. + return rows.size() > 1 ? rows : java.util.Collections.emptyList(); } private java.util.List> retrieveJointHolders(final Long accountId) { @@ -389,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead }, accountId); } + /** + * Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the + * Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it + * needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for + * it and would fall through to paying the whole balance out in cash. + */ @Override public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) { + final boolean includePartialReinvest = false; + return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest); + } + + /** + * The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that + * implements it. + */ + @Override + public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) { + final boolean includePartialReinvest = true; + return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest); + } + + private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId, + final boolean includePartialReinvest) { try { this.context.authenticatedUser(); @@ -407,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue()); Collection onAccountClosureOptions = SavingsEnumerations - .depositAccountOnClosureType(DepositAccountOnClosureType.values()); + .depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values()) + .filter(type -> includePartialReinvest || !type.isPartialReinvest()) + .toArray(DepositAccountOnClosureType[]::new)); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService .retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index fe4c4a6c8..dd60c181e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -912,6 +913,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } + @Override + public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) { + final AppUser user = this.context.authenticatedUser(); + + this.depositAccountTransactionDataValidator.validatePayment(command); + + final Map changes = new LinkedHashMap<>(); + final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); + + // Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close + // (Active only) -- but it must still be one of those two. Without this, a closed or + // pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to + // ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it. + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId, + DepositAccountType.FIXED_DEPOSIT); + if (!account.isActive() && !account.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured", + "Payment can only be processed on an active or matured fixed deposit account.", savingsId); + } + checkClientOrGroupActive(account); + + this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); + + // Reinvest Principal Only opens a second account and the domain method withdrew the principal out + // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually + // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + if (changes.get("reinvestedDepositId") != null) { + final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); + final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler + .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); + Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); + final Money activationChargeAmount = getActivationCharge(reinvestAccount); + if (activationChargeAmount.isGreaterThanZero()) { + payActivationCharge(reinvestAccount); + amountForDeposit = amountForDeposit.plus(activationChargeAmount); + } + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + final boolean isInternalTransferLeg = true; + this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null, + isInternalTransferLeg); + } + + final String noteText = command.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.savingNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + + return new CommandProcessingResultBuilder() // + .withEntityId(savingsId) // + .withOfficeId(account.officeId()) // + .withClientId(account.clientId()) // + .withGroupId(account.groupId()) // + .withSavingsId(savingsId) // + .with(changes)// + .build(); + + } + @Override public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) { final AppUser user = this.context.authenticatedUser(); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 22f8bf57a..ebdcdf988 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -538,4 +538,12 @@ + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml new file mode 100644 index 000000000..1df834745 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml new file mode 100644 index 000000000..5be47a3ed --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml new file mode 100644 index 000000000..da8f9f5bc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml new file mode 100644 index 000000000..a57c9ba9c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + end_date IS NOT NULL AND end_date < CURRENT_DATE + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml new file mode 100644 index 000000000..ae7ab6e95 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml new file mode 100644 index 000000000..598873346 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml new file mode 100644 index 000000000..950d0f94b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml new file mode 100644 index 000000000..1c10cc42f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml @@ -0,0 +1,29 @@ + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT', 'FIXEDDEPOSITACCOUNT', 'PAYMENT', true + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT'); + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER', 'FIXEDDEPOSITACCOUNT', 'PAYMENT_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER'); + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java index bac91df40..be62adada 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java @@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS import com.google.gson.JsonArray; import com.google.gson.JsonObject; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom { @OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private Set chartSlabs = new HashSet<>(); + /** + * Whether this is a rate version the product currently offers. + *

+ * Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate + * act and the dates stay purely the historical record. New charts start active. + */ + @Column(name = "is_active", nullable = false) + private boolean active = true; + + public boolean isActive() { + return this.active; + } + + /** Retires this rate version, so it stops being picked for new and renewing deposits. */ + public void deactivate() { + this.active = false; + } + + public void activate() { + this.active = true; + } + protected InterestRateChart() { // } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java index e1a78a96d..433729acb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java @@ -123,6 +123,10 @@ public class InterestRateChartFields { return compare != null && DateUtils.isAfter(getFromDate(), compare); } + public String getName() { + return this.name; + } + public LocalDate getFromDate() { return this.fromDate; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java index 2e97e34e9..50b891708 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java @@ -177,6 +177,10 @@ public class InterestRateChartSlabFields { return isGreater; } + public String description() { + return this.description; + } + public Integer periodType() { return this.periodType; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java index 42ab9cb39..fbb49e195 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java @@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData { private final BigDecimal annualInterestRate; private final CurrencyData currency; + /** + * The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the + * account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced + * off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab. + */ + private final BigDecimal seniorCitizenRate; + // associations private Collection incentives; @@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData { public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType, final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, - final BigDecimal annualInterestRate, final CurrencyData currency) { + final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) { final Collection periodTypes = null; final Collection entityTypeOptions = null; final Collection attributeNameOptions = null; @@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData { final Collection incentives = null; return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom, amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions, - conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions); + conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions, + seniorCitizenRate); } public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) { @@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData { fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive)); } } + // the product-level chart this comes from has no senior-citizen concept at all + final BigDecimal seniorCitizenRate = null; return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(), chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(), chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(), chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(), chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(), - chartSlabData.clientClassificationOptions()); + chartSlabData.clientClassificationOptions(), seniorCitizenRate); } public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab, @@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData { return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod, chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives, chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions, - genderOptions, clientTypeOptions, clientClassificationOptions); + genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate); } private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType, @@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData { final CurrencyData currency, final Collection periodTypes, final Collection entityTypeOptions, final Collection attributeNameOptions, final Collection conditionTypeOptions, final Collection incentiveTypeOptions, final Collection genderOptions, - final Collection clientTypeOptions, final Collection clientClassificationOptions) { + final Collection clientTypeOptions, final Collection clientClassificationOptions, + final BigDecimal seniorCitizenRate) { this.id = id; this.description = description; this.periodType = periodType; @@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData { this.amountRangeTo = amountRangeTo; this.annualInterestRate = annualInterestRate; this.currency = currency; + this.seniorCitizenRate = seniorCitizenRate; this.periodTypes = periodTypes; this.incentives = incentivesData; this.attributeNameOptions = attributeNameOptions; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index d67d12baf..c9fd58529 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -155,6 +155,33 @@ public final class FixedDepositAccountData extends DepositAccountData { this.reinvestedIntoAccountNo = reinvestedIntoAccountNo; } + // The account number the customer should keep seeing across renewals -- the ORIGINAL account's + // account_no, carried forward onto every account created by reinvesting it. Falls back to this + // account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl). + private String fdAccountNumber; + + public void setFdAccountNumber(final String fdAccountNumber) { + this.fdAccountNumber = fdAccountNumber; + } + + /** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */ + private Integer renewalCycleNo; + + public void setRenewalCycleNo(final Integer renewalCycleNo) { + this.renewalCycleNo = renewalCycleNo; + } + + /** + * Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own + * account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit + * certificate present "one FD, many terms" without the caller having to walk the chain itself. + */ + private java.util.List> renewalHistory; + + public void setRenewalHistory(final java.util.List> renewalHistory) { + this.renewalHistory = renewalHistory; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; @@ -350,6 +377,9 @@ public final class FixedDepositAccountData extends DepositAccountData { this.reinvestedFromAccountNo = src.reinvestedFromAccountNo; this.reinvestedIntoAccountId = src.reinvestedIntoAccountId; this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo; + this.fdAccountNumber = src.fdAccountNumber; + this.renewalCycleNo = src.renewalCycleNo; + this.renewalHistory = src.renewalHistory; this.subStatus = src.subStatus; this.reasonForBlock = src.reasonForBlock; this.lastActiveTransactionDate = src.lastActiveTransactionDate; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java index 4e2e1389f..e2007c80e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java @@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab; import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation; import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory; @@ -156,4 +160,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom + * Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the + * renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather + * than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so + * a second row for the same account would break loading it. + */ + public void refreshFrom(final InterestRateChart productChart) { + this.chartFields = productChart.chartFields(); + // orphanRemoval on chartSlabs deletes the superseded slab rows. + setOfChartSlabs().clear(); + for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) { + this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this)); + } + } + + /** + * A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together. + *

+ * The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the + * product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair + * rather than being added to the shared type. + */ + public static final class SlabSnapshot { + + private final InterestRateChartSlabFields fields; + private final BigDecimal seniorCitizenRate; + + public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) { + this.fields = fields; + this.seniorCitizenRate = seniorCitizenRate; + } + + public InterestRateChartSlabFields getFields() { + return this.fields; + } + + public BigDecimal getSeniorCitizenRate() { + return this.seniorCitizenRate; + } + } + + /** + * This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before + * {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than + * losing its rate card the moment the new term's rates replace it. + */ + public List currentSlabSnapshots() { + final List snapshots = new ArrayList<>(); + for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) { + snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate())); + } + return snapshots; + } + + /** + * Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place. + *

+ * Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that + * master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would + * disagree with what the bank publishes. Mutates the existing row for the same reason + * {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's + * savings_account_id, so a second row for the same account would break loading it. + *

+ * Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate + * version can never leave a deposit with no rates at all. + */ + public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + if (chartFields != null) { + this.chartFields = chartFields; + } + setOfChartSlabs().clear(); + for (final SlabSnapshot snapshot : snapshots) { + this.chartSlabs + .add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate())); + } + } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java index 119185a99..0a3f5d588 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.savings.domain; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.math.BigDecimal; import java.util.HashSet; import java.util.Set; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; @@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus @OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private Set interestIncentives = new HashSet<>(); + /** + * The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the + * time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the + * product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its + * own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries + * one) or for a slab that predates this column. + */ + @Column(name = "senior_citizen_rate", scale = 6, precision = 19) + private BigDecimal seniorCitizenRate; + protected DepositAccountInterestRateChartSlabs() { // } @@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus this.interestIncentives = interestIncentives; } + public BigDecimal getSeniorCitizenRate() { + return this.seniorCitizenRate; + } + public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) { this.depositAccountInterestRateChart = depositAccountInterestRateChart; } @@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus return this.slabFields; } + /** + * Builds a slab straight from its field values, for a chart taken from a source that is not a product + * {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate + * card and carries no incentive rules. + */ + public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields, + final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) { + final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields, + depositAccountInterestRateChart, new HashSet<>()); + slab.seniorCitizenRate = seniorCitizenRate; + return slab; + } + public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab, DepositAccountInterestRateChart depositAccountInterestRateChart) { InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields(); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index 37b3db921..9f15e9102 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -104,6 +104,34 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom + * These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term, + * so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again. + */ + public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) { + this.onAccountClosureType = previousTerm.onAccountClosureType; + this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId; + this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount; + } + public boolean isReinvestOnClosure() { return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest(); } @@ -332,6 +373,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom + * Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the + * active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a + * deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered + * set, so two charts covering the same date resolve arbitrarily. + *

+ * Returns null when the product offers no active chart in force on that date. + */ + public InterestRateChart activeChart(final LocalDate target) { + if (this.charts == null || target == null) { + return null; + } + InterestRateChart activeChart = null; + for (final InterestRateChart chart : this.charts) { + if (!chart.isActive()) { + continue; // retired rate version + } + final LocalDate fromDate = chart.getFromDate(); + final LocalDate endDate = chart.getEndDate(); + if (fromDate == null || fromDate.isAfter(target)) { + continue; // not yet in force + } + if (endDate != null && endDate.isBefore(target)) { + continue; // already superseded + } + if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) { + activeChart = chart; + } + } + return activeChart; + } + public DepositProductTermAndPreClosure depositProductTermAndPreClosure() { return this.productTermAndPreClosure; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 85b0bc8bb..f64d3e481 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -828,6 +828,28 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return savingsTransaction; } + /** + * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. + *

+ * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening + * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the + * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match + * would leave the opening balance at zero on any pivot date that happens to carry no transaction. + *

+ * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final + * match is the latest one. + */ + protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { + SavingsAccountTransaction candidate = null; + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && DateUtils.isBefore(transaction.getTransactionDate(), date)) { + candidate = transaction; + } + } + return candidate; + } + protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { SavingsAccountTransaction savingsTransaction = null; List trans = getSavingsAccountTransactionsWithPivotConfig(); @@ -921,13 +943,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom if (backdatedTxnsAllowedTill) { transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); } else { - transaction = findLastTransaction(startInterestCalculationDate); + // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the + // first posting period and is applied there, so seeding from it would apply it twice -- + // and an exact-date match would leave this at zero whenever the pivot date carries no + // transaction of its own, silently accruing the whole term on a zero balance. + transaction = findLastTransactionBefore(startInterestCalculationDate); } if (transaction == null) { periodStartingBalance = Money.zero(this.currency); - } else { + } else if (backdatedTxnsAllowedTill) { periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } else { + // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the + // assembler on the backdated-transactions path, so reading it here returned zero and + // silently wiped the opening balance. The balance carried by the last transaction + // before the start date is the same figure, and is always present. + periodStartingBalance = transaction.getRunningBalance(this.currency); } } else { periodStartingBalance = Money.zero(this.currency); @@ -3140,7 +3172,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } } - private LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { + protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { LocalDate lockedInUntilLocalDate = null; final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 90bb30861..57a2828df 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -61,6 +61,8 @@ public interface DepositAccountWritePlatformService { CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command); + CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command); + SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType); SavingsAccountTransaction withdrawSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType); From 73ec85cfd6de77296ba0394eff84374684ed6b97 Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 25 Aug 2026 11:31:49 +0530 Subject: [PATCH 08/55] feat: implement account register, branch day, voucher verification, and share dividend backend APIs --- .../common/AccountingConstants.java | 1 + .../infrastructure/jobs/service/JobName.java | 1 + .../DividendYearwiseReportApiResource.java | 185 +++ .../api/DistributableProfitApiResource.java | 278 +++++ .../api/IncomeProfitTransferApiResource.java | 180 +++ .../PostShareEntryFeeToProfitConfig.java | 60 + .../PostShareEntryFeeToProfitTasklet.java | 61 + .../service/IncomeProfitTransferService.java | 377 ++++++ .../api/AccountRegisterApiResource.java | 299 +++++ .../AmlFundTransferEvaluationService.java | 5 +- .../service/AmlRuleEvaluationServiceImpl.java | 11 +- .../api/BranchDayRegisterApiResource.java | 477 ++++++++ .../cashreport/api/CashReportApiResource.java | 173 +++ .../ShareAdvanceCollectionApiResource.java | 754 ++++++++++++ .../api/ShareCollectionApiResource.java | 16 +- .../ShareSavingsDebitRequestApiResource.java | 601 ++++++++++ .../ClerkDisbursementRequestApiResource.java | 22 +- .../api/DividendClaimRequestApiResource.java | 763 +++++------- .../api/DividendDashboardApiResource.java | 124 +- .../shareaccounts/data/ShareAccountData.java | 14 + .../domain/ShareAccountCharge.java | 9 +- .../ForfeitUnclaimedDividendsTasklet.java | 16 +- .../ShareAccountDataSerializer.java | 23 +- .../ShareAccountReadPlatformServiceImpl.java | 4 + .../ShareAccountSchedularServiceImpl.java | 78 +- ...WritePlatformServiceJpaRepositoryImpl.java | 1050 +++++++++-------- .../start/ShareAccountsConfiguration.java | 3 +- .../api/AllProductsDividendApiResource.java | 579 +++++++++ .../api/ShareDividendApiResource.java | 33 + ...hareDividendCapitalPreviewApiResource.java | 262 ++++ .../ShareProductDividendPayOutDetails.java | 18 + ...areDividendRoundingResidueReadService.java | 113 ++ .../ShareProductDividendAssembler.java | 55 +- ...WritePlatformServiceJpaRepositoryImpl.java | 586 ++++++++- .../starter/ShareProductsConfiguration.java | 4 +- .../api/VoucherVerificationApiResource.java | 429 +++++++ .../voucher/service/VoucherService.java | 105 +- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 21 + ...0_add_resolution_date_to_share_account.xml | 33 + ...71_make_share_account_savings_optional.xml | 31 + .../0472_create_share_advance_collection.xml | 88 ++ ...dd_collection_applied_to_share_account.xml | 43 + ...voucher_no_to_share_advance_collection.xml | 42 + ...5_add_share_applied_to_deposit_request.xml | 36 + .../0476_create_income_profit_transfer.xml | 89 ++ .../0477_add_share_entry_fee_profit_job.xml | 50 + ...dend_appropriation_accounts_and_config.xml | 116 ++ .../0479_dividend_claim_by_demand_draft.xml | 65 + .../0480_create_dividend_rounding_residue.xml | 146 +++ .../parts/0481_seed_dividend_paid_gl.xml | 68 ++ ...482_create_share_savings_debit_request.xml | 120 ++ ..._seed_share_savings_debit_payment_type.xml | 54 + .../parts/0484_voucher_no_dated_format.xml | 78 ++ ...485_backfill_flat_share_charge_amounts.xml | 36 + ...pay_unsettled_share_activation_charges.xml | 44 + .../0487_create_voucher_verification.xml | 81 ++ .../0488_add_voucher_exception_resolution.xml | 33 + .../parts/0489_create_branch_day_register.xml | 100 ++ ..._seed_bank_profit_non_distributable_gl.xml | 76 ++ 60 files changed, 8075 insertions(+), 1148 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index 0713de32c..e79c7d784 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -437,6 +437,7 @@ public final class AccountingConstants { OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), // ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), // PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), // + DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), // RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), // DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); // diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 1812b2bb1..aca7da9a6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -39,6 +39,7 @@ public enum JobName { GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), // POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), // FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // + POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java new file mode 100644 index 000000000..88e5e87ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/dividendpaidreport/api/DividendYearwiseReportApiResource.java @@ -0,0 +1,185 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.dividendpaidreport.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +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.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Every declared dividend, financial year by financial year: how much was declared, how much members have collected, + * how much is still unclaimed, and how much has come back to profit. + * + * The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is + * still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the + * per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the + * money moved; the locks are what it moved for. + * + * The columns mirror the money's actual path, which begins and ends in the same place: + * + *

+ *   declare          Dr Bank Profit        Cr Dividend Payable
+ *   member claims    Dr Dividend Payable   Cr Dividend Paid  →  Cr Sponsor DD Payable
+ *   3 yrs unclaimed  Dr Dividend Payable   Cr Bank Profit
+ * 
+ * + * So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left + * is what is still sitting unclaimed and still within its three-year window. + */ +@Path("/v1/accounting/dividend-yearwise-report") +@Component +@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.") +@RequiredArgsConstructor +public class DividendYearwiseReportApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String report() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + // A declaration is labelled by the financial year its period CLOSES in, which is how a member + // and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined + // through the per-member detail rather than counted off the declaration, because a lock is only + // created for a member who actually had something to collect. + // Declared amounts and lock outcomes are aggregated separately before being joined by year. + // Summing the declaration amount across a join to its locks would multiply it by the number + // of members it was split between. + final List> rows = jdbcTemplate.queryForList(""" + WITH fy AS ( + SELECT d.id, + CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4 + THEN EXTRACT(YEAR FROM d.dividend_period_end_date) + ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start, + d.amount + FROM m_share_product_dividend_pay_out d + ), + declared AS ( + SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount + FROM fy GROUP BY fy_start + ), + outcomes AS ( + SELECT fy.fy_start, + COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount, + COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count, + COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount, + COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count, + COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount, + COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count, + MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due + FROM fy + LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id + LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id + GROUP BY fy.fy_start + ) + SELECT d.fy_start, d.declaration_count, d.declared_amount, + COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count, + COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count, + COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count, + o.earliest_release_due + FROM declared d + LEFT JOIN outcomes o ON o.fy_start = d.fy_start + ORDER BY d.fy_start DESC + """); + + // Rounding residue is tracked per declaration rather than per lock — it never belonged to a + // member, so no lock was ever created for it. Pulled separately and matched by year. + final Map residueByYear = new LinkedHashMap<>(); + for (final Map row : jdbcTemplate.queryForList(""" + SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4 + THEN EXTRACT(YEAR FROM period_end_date) + ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start, + COALESCE(SUM(residue_amount), 0) AS residue + FROM m_share_dividend_rounding_residue + GROUP BY fy_start + """)) { + residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue")); + } + + final List> years = new ArrayList<>(); + BigDecimal totalDeclared = BigDecimal.ZERO; + BigDecimal totalClaimed = BigDecimal.ZERO; + BigDecimal totalUnclaimed = BigDecimal.ZERO; + BigDecimal totalForfeited = BigDecimal.ZERO; + + for (final Map row : rows) { + final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue()); + final BigDecimal declared = (BigDecimal) row.get("declared_amount"); + final BigDecimal claimed = (BigDecimal) row.get("claimed_amount"); + final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount"); + final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount"); + final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO); + + final Map entry = new LinkedHashMap<>(); + entry.put("financialYear", financialYear); + entry.put("declarationCount", row.get("declaration_count")); + entry.put("declaredAmount", declared); + entry.put("claimedAmount", claimed); + entry.put("claimedCount", row.get("claimed_count")); + entry.put("unclaimedAmount", unclaimed); + entry.put("unclaimedCount", row.get("unclaimed_count")); + entry.put("forfeitedAmount", forfeited); + entry.put("forfeitedCount", row.get("forfeited_count")); + entry.put("roundingResidueAmount", residue); + // What has already gone back to profit for this year: forfeited entitlements plus the + // rounding residue swept at declaration time. + entry.put("returnedToProfitAmount", forfeited.add(residue)); + // When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed + // amount starts returning to profit. + entry.put("earliestReleaseDueDate", + row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due"))); + years.add(entry); + + totalDeclared = totalDeclared.add(declared); + totalClaimed = totalClaimed.add(claimed); + totalUnclaimed = totalUnclaimed.add(unclaimed); + totalForfeited = totalForfeited.add(forfeited); + } + + final Map response = new LinkedHashMap<>(); + response.put("years", years); + response.put("totalDeclared", totalDeclared); + response.put("totalClaimed", totalClaimed); + response.put("totalUnclaimed", totalUnclaimed); + response.put("totalForfeited", totalForfeited); + return gson.toJson(response); + } + + private String financialYearLabel(final int startYear) { + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java new file mode 100644 index 000000000..1e03e915e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java @@ -0,0 +1,278 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.profitandloss.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Date; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * How much profit a share dividend may actually be declared out of, for a given period. + * + * A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve + * Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared + * for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one + * place so the declaration screen and the declaration-time validation cannot disagree about the ceiling. + * + * Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration + * row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error. + */ +@Path("/v1/accounting/distributable-profit") +@Component +@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.") +@RequiredArgsConstructor +public class DistributableProfitApiResource { + + /** + * The year's closed-out profit, which is what a dividend may be declared against; kept in step with + * IncomeProfitTransferService.PROFIT_GL_CODE. + * + * Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would + * offer profit already distributed once for distribution again. + */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; + private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage"; + + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("excludeProductId") final Long excludeProductId) { + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final Date from; + final Date to; + try { + from = Date.valueOf(fromDate); + to = Date.valueOf(toDate); + } catch (final IllegalArgumentException e) { + throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format"); + } + if (to.before(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + + // A dividend is normally declared for a whole, closed financial year, and then the audited + // profit in Bank Profit is the figure to use. A period that is not a full financial year can + // only be an interim declaration, which necessarily runs against the still-open P&L. + final String financialYear = financialYearOf(from.toLocalDate()); + final LocalDate[] fyRange = financialYearRange(financialYear); + final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]); + + final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null; + final boolean yearClosed = closedProfit != null; + final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to); + + final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); + final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); + final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE); + + // Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate + // and nothing to distribute, so the whole waterfall collapses to zero rather than producing + // a negative "reserve" that would read as money owed back to the society. + final boolean profitable = netProfit.signum() > 0; + final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO; + final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO; + + final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount) + : BigDecimal.ZERO; + + final List> existingDeclarations = declarationsOverlapping(from, to, excludeProductId); + BigDecimal alreadyDeclared = BigDecimal.ZERO; + for (final Map declaration : existingDeclarations) { + alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount")); + } + + // An over-declaration that already happened (or appropriation rates raised after the fact) + // must not present as negative headroom, which a caller could read as a credit. + final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO); + + final Map response = new LinkedHashMap<>(); + response.put("fromDate", fromDate); + response.put("toDate", toDate); + response.put("financialYear", financialYear); + response.put("coversWholeFinancialYear", coversWholeFinancialYear); + response.put("yearClosed", yearClosed); + // Names the figure so the screen can say where it came from — the closed books, or a P&L + // that is still moving — rather than showing a bare number the officer has to interpret. + response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS"); + response.put("netProfit", netProfit); + response.put("statutoryReservePercentage", statutoryReservePercentage); + response.put("statutoryReserveAmount", statutoryReserveAmount); + response.put("educationFundPercentage", educationFundPercentage); + response.put("educationFundAmount", educationFundAmount); + response.put("distributableProfit", distributableProfit); + response.put("alreadyDeclared", alreadyDeclared); + response.put("availableForDeclaration", availableForDeclaration); + response.put("maximumRatePercentage", maximumRatePercentage); + response.put("existingDeclarations", existingDeclarations); + return gson.toJson(response); + } + + /** + * The year's profit as the books actually record it, once the year has been closed. + * + * A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the + * Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather + * than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a + * closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it. + * + * Returns null when the year has not been closed, which the caller reports as a blocking condition rather than + * silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit + * figure that is still moving. + */ + private BigDecimal closedProfitForFinancialYear(final String financialYear) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount == null || closedCount == 0) { + return null; + } + final LocalDate[] range = financialYearRange(financialYear); + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** "2026-27" -> 1 April 2026 to 31 March 2027. */ + private LocalDate[] financialYearRange(final String financialYear) { + try { + final int startYear = Integer.parseInt(financialYear.substring(0, 4)); + return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) }; + } catch (final RuntimeException e) { + throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear); + } + } + + /** The Indian financial year a date falls in, as a "YYYY-YY" label. */ + private String financialYearOf(final LocalDate date) { + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** + * Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal + * entries only. Used only for the interim case — a period inside a year that has not been closed yet. + */ + private BigDecimal netProfitForPeriod(final Date from, final Date to) { + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false + AND je.entry_date BETWEEN ? AND ? + """, from, to); + final BigDecimal totalIncome = (BigDecimal) row.get("total_income"); + final BigDecimal totalExpense = (BigDecimal) row.get("total_expense"); + return totalIncome.subtract(totalExpense); + } + + /** + * Every dividend already declared against a period overlapping this one, across ALL share products — the + * appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable + * profit as the first. + * + * excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping + * declaration is separately blocked outright. + */ + private List> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) { + final StringBuilder sql = new StringBuilder(""" + SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount, + d.dividend_period_start_date, d.dividend_period_end_date, d.status + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ? + """); + final List params = new ArrayList<>(List.of(to, from)); + if (excludeProductId != null) { + sql.append(" AND d.product_id <> ? "); + params.add(excludeProductId); + } + sql.append(" ORDER BY d.dividend_period_start_date, d.id"); + + final List> declarations = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map declaration = new LinkedHashMap<>(); + declaration.put("declarationId", row.get("declaration_id")); + declaration.put("productId", row.get("product_id")); + declaration.put("productName", row.get("product_name")); + declaration.put("amount", row.get("amount")); + declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date"))); + declaration.put("status", row.get("status")); + declarations.add(declaration); + } + return declarations; + } + + /** + * A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling), + * which is 0 — not a failure. Only an enabled row with a value applies. + */ + private BigDecimal configuredPercentage(final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + + private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) { + return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java new file mode 100644 index 000000000..abae1f99f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java @@ -0,0 +1,180 @@ +package org.apache.fineract.accounting.yearend.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Financial year-end closing of the share entry fee into the Bank Profit equity GL. + * + *

+ * {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST} + * performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so + * this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early. + */ +@Path("/v1/accounting/incomeprofittransfer") +@Component +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class IncomeProfitTransferApiResource { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final IncomeProfitTransferService service; + private final Gson gson = new Gson(); + + // ══════════════════════════════════════════════════════════════════════ + // GET — how much is waiting to be posted, and what has been posted before + // ══════════════════════════════════════════════════════════════════════ + @GET + public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam, + @QueryParam("financialYear") final String financialYearParam) { + + this.context.authenticatedUser(); + + final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank() + ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE + : incomeGlCodeParam.trim(); + final Map incomeGl = service.loadGlByCode(incomeGlCode); + final Map profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE); + + final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear() + : financialYearParam.trim(); + final LocalDate[] range = service.financialYearRange(financialYear); + + final Long incomeGlId = toLong(incomeGl.get("id")); + final BigDecimal pending = service.balanceOf(incomeGlId, range); + final int entryCount = service.entryCountOf(incomeGlId, range); + + final List> history = jdbcTemplate + .queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, " + + " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name " + + " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id " + + " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId); + + final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year"))); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("fromDate", range[0].toString()); + out.put("toDate", range[1].toString()); + out.put("incomeGlCode", incomeGl.get("gl_code")); + out.put("incomeGlName", incomeGl.get("name")); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + out.put("pendingAmount", pending); + out.put("entryCount", entryCount); + out.put("alreadyPosted", alreadyPosted); + out.put("postedHistory", history); + out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST — close the income GL into Bank Profit + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + public Response post(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE + : getString(json, "incomeGlCode").trim(); + final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear() + : getString(json, "financialYear").trim(); + + // strict = true: a manual posting reports "already closed" or "nothing to post" as an + // error, so the user is told why nothing happened. + final Map result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(), + sanitiseNote(getString(json, "note")), true); + + return Response.ok(gson.toJson(result)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /close-year — close the WHOLE P&L into Bank Profit + // ══════════════════════════════════════════════════════════════════════ + + /** + * Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit. + * + * This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower + * share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that + * has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts. + */ + @POST + @Path("/close-year") + @Transactional + public Response closeYear(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear() + : getString(json, "financialYear").trim(); + + // strict = true: a manual close reports "nothing left to close" as an error, so the user is + // told why nothing happened rather than getting a silent success. + final Map result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(), + sanitiseNote(getString(json, "note")), true); + + return Response.ok(gson.toJson(result)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + return new JsonObject(); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON."); + } + } + + private String sanitiseNote(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + final String trimmed = raw.trim(); + return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed; + } + + private String getString(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java new file mode 100644 index 000000000..d237b8c5b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitConfig.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit; + +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +import org.apache.fineract.infrastructure.jobs.service.JobName; +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; + +@Configuration +public class PostShareEntryFeeToProfitConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private IncomeProfitTransferService incomeProfitTransferService; + + @Bean + protected Step postShareEntryFeeToProfitStep() { + return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository) + .tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build(); + } + + @Bean + public Job postShareEntryFeeToProfitJob() { + return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep()) + .incrementer(new RunIdIncrementer()).build(); + } + + @Bean + public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() { + return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java new file mode 100644 index 000000000..4f12d5628 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/jobs/postshareentryfeetoprofit/PostShareEntryFeeToProfitTasklet.java @@ -0,0 +1,61 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService; +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; + +/** + * Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank + * Profit equity GL, leaving the income account at nil for the new year. + * + *

+ * Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged + * rather than failing the job. Only a genuine posting failure is raised. + */ +@Slf4j +@RequiredArgsConstructor +public class PostShareEntryFeeToProfitTasklet implements Tasklet { + + /** Fineract's system user id, used because a scheduled run has no logged-in user. */ + private static final long SYSTEM_USER_ID = 1L; + + private final IncomeProfitTransferService incomeProfitTransferService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + final String financialYear = incomeProfitTransferService.currentFinancialYear(); + + // Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit + // is now what the share dividend declaration reads as "the year's profit", and a partial close + // would leave most of the profit still sitting in the income GLs — understating what may be + // distributed. Per-GL guards inside closeAllToProfit make a re-run safe. + final Map result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system", + "Automatic year-end closing", false); + + log.info("Year-end closing for {}: {}", financialYear, result.get("message")); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java new file mode 100644 index 000000000..464ddee50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -0,0 +1,377 @@ +package org.apache.fineract.accounting.yearend.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +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.core.service.DateUtils; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes an income GL into the Bank Profit equity GL at financial year end. + * + *

+ *   Dr  Share Entry Fee Income (10.4)   = balance for the year
+ *   Cr  Bank Profit (BANKPROFIT01)
+ * 
+ * + * Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as + * retained profit. + * + *

+ * Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are + * protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a + * logged-in user. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class IncomeProfitTransferService { + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int DEBIT = 2; + private static final int CREDIT = 1; + + /** Equity GL that accumulates profit. */ + /** + * The year's profit is closed into the Non-Distributable account, not plain Bank Profit. + * + * A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once + * the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years' + * leftovers, and the next declaration would be sized on profit that had already been through a distribution + * decision — offering the same money for distribution twice. + */ + public static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + /** Income GL closed to profit by the year-end job — the share entry fee. */ + public static final String DEFAULT_INCOME_GL_CODE = "10.4"; + + private final JdbcTemplate jdbcTemplate; + + /** + * Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when + * there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not + * errors. + * + * @param strict + * when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it + * returns quietly. + */ + @Transactional + public Map postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName, + final String note, final boolean strict) { + + final Map incomeGl = loadGlByCode(incomeGlCode); + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Long incomeGlId = toLong(incomeGl.get("id")); + final Long profitGlId = toLong(profitGl.get("id")); + + final LocalDate[] range = financialYearRange(financialYear); + final Long officeId = headOfficeId(); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("incomeGlCode", incomeGl.get("gl_code")); + out.put("incomeGlName", incomeGl.get("name")); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?", + Integer.class, financialYear, incomeGlId, officeId); + if (existing != null && existing > 0) { + if (strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted", + incomeGl.get("name") + " has already been closed to profit for " + financialYear + "."); + } + out.put("posted", false); + out.put("reason", "Already closed for " + financialYear); + return out; + } + + final BigDecimal balance = balanceOf(incomeGlId, range); + if (balance.compareTo(BigDecimal.ZERO) <= 0) { + if (strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post", + "There is no income in " + incomeGl.get("name") + " to close for " + financialYear + "."); + } + out.put("posted", false); + out.put("reason", "No income to close for " + financialYear); + return out; + } + + final int entryCount = entryCountOf(incomeGlId, range); + final String currencyCode = resolveCurrency(incomeGlId); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate postingDate = range[1]; + final String txnId = "YEPROFIT-" + System.nanoTime(); + final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit" + + (note != null ? " | " + note : ""); + + insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc); + insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, + "Profit for " + financialYear + " from " + incomeGl.get("name")); + + jdbcTemplate.update( + "INSERT INTO m_income_profit_transfer " + + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, " + + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, + entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note); + + log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"), + financialYear); + + out.put("posted", true); + out.put("amountPostedToProfit", balance); + out.put("entryCount", entryCount); + out.put("postedOnDate", postingDate.toString()); + out.put("glTransactionId", txnId); + out.put("currency", currencyCode); + out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + "."); + return out; + } + + /** + * Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real + * net profit. + * + *

+     *   each income GL:    Dr  Income GL      Cr  Bank Profit     (income raises profit)
+     *   each expense GL:   Dr  Bank Profit    Cr  Expense GL      (expense reduces profit)
+     * 
+ * + * {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes + * the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" — + * the share dividend declaration in particular — depends on this having run, because a partial close leaves the + * profit split across two places and understates it. + * + * Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a + * re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was. + * Accounts with a nil balance are skipped rather than posted as zero entries. + */ + @Transactional + public Map closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note, + final boolean strict) { + + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Long profitGlId = toLong(profitGl.get("id")); + final LocalDate[] range = financialYearRange(financialYear); + final Long officeId = headOfficeId(); + + // 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a + // historical balance but must not attract new postings. + final List> plAccounts = jdbcTemplate.queryForList( + "SELECT id, gl_code, name, classification_enum FROM acc_gl_account " + + " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code"); + + final List> closed = new ArrayList<>(); + final List> skipped = new ArrayList<>(); + BigDecimal totalIncome = BigDecimal.ZERO; + BigDecimal totalExpense = BigDecimal.ZERO; + + for (final Map gl : plAccounts) { + final Long glId = toLong(gl.get("id")); + final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4; + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?", + Integer.class, financialYear, glId, officeId); + if (existing != null && existing > 0) { + skipped.add(skipRow(gl, "Already closed for " + financialYear)); + continue; + } + + // balanceOf returns credit-less-debit. For an income GL that is the income earned; for an + // expense GL the natural balance is a debit, so the same figure comes back negative and + // is flipped here to read as a positive expense. + final BigDecimal rawBalance = balanceOf(glId, range); + final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate(); + if (balance.compareTo(BigDecimal.ZERO) <= 0) { + skipped.add(skipRow(gl, "Nothing to close for " + financialYear)); + continue; + } + + final int entryCount = entryCountOf(glId, range); + final String currencyCode = resolveCurrency(glId); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate postingDate = range[1]; + final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime(); + final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit" + + (note != null ? " | " + note : ""); + + if (isIncome) { + insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr); + insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, + "Profit for " + financialYear + " from " + gl.get("name")); + totalIncome = totalIncome.add(balance); + } else { + insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr); + insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, + "Expense for " + financialYear + " from " + gl.get("name")); + totalExpense = totalExpense.add(balance); + } + + jdbcTemplate.update("INSERT INTO m_income_profit_transfer " + + "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, " + + " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId, + java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId, + java.sql.Date.valueOf(postingDate), userId, userName, note); + + final Map row = new LinkedHashMap<>(); + row.put("glCode", gl.get("gl_code")); + row.put("glName", gl.get("name")); + row.put("type", isIncome ? "INCOME" : "EXPENSE"); + row.put("amount", balance); + row.put("entryCount", entryCount); + row.put("glTransactionId", txnId); + closed.add(row); + } + + if (closed.isEmpty() && strict) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close", + "There is nothing left to close to profit for " + financialYear + "."); + } + + final BigDecimal netProfit = totalIncome.subtract(totalExpense); + log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense, + netProfit, profitGl.get("name")); + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYear); + out.put("fromDate", range[0].toString()); + out.put("toDate", range[1].toString()); + out.put("profitGlCode", profitGl.get("gl_code")); + out.put("profitGlName", profitGl.get("name")); + out.put("totalIncomeClosed", totalIncome); + out.put("totalExpenseClosed", totalExpense); + out.put("netProfitPosted", netProfit); + out.put("accountsClosed", closed); + out.put("accountsSkipped", skipped); + out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit " + + netProfit + "."); + return out; + } + + private Map skipRow(final Map gl, final String reason) { + final Map row = new LinkedHashMap<>(); + row.put("glCode", gl.get("gl_code")); + row.put("glName", gl.get("name")); + row.put("reason", reason); + return row; + } + + /** + * The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the + * year has been closed. + * + * Only entries dated inside the financial year count, so each year's profit is separable rather than being one + * ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide + * whether that is a blocking condition. + */ + public BigDecimal profitForFinancialYear(final String financialYear) { + final LocalDate[] range = financialYearRange(financialYear); + final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final BigDecimal balance = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " + + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?", + BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** Whether the year's P&L has been closed into Bank Profit at all. */ + public boolean isYearClosed(final String financialYear) { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", + Integer.class, financialYear); + return count != null && count > 0; + } + + /** Net credit balance sitting in the income GL for the year. */ + public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) { + final BigDecimal balance = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " + + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?", + BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** How many fee postings made up that balance. */ + public int entryCountOf(final Long incomeGlId, final LocalDate[] range) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + + " AND type_enum = ? AND entry_date BETWEEN ? AND ?", + Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + return count == null ? 0 : count; + } + + /** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */ + public String currentFinancialYear() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** 1 April to 31 March for a "YYYY-YY" label. */ + public LocalDate[] financialYearRange(final String financialYear) { + try { + final int startYear = Integer.parseInt(financialYear.substring(0, 4)); + return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) }; + } catch (RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid", + "financialYear must look like 2026-27. Received: " + financialYear); + } + } + + public Map loadGlByCode(final String glCode) { + try { + return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist."); + } + } + + public Long headOfficeId() { + return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); + } + + private String resolveCurrency(final Long glAccountId) { + try { + final String code = jdbcTemplate.queryForObject( + "SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class, + glAccountId); + return code == null ? "INR" : code; + } catch (EmptyResultDataAccessException e) { + return "INR"; + } + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) { + jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId, + now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC), + now.atOffset(ZoneOffset.UTC)); + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java new file mode 100644 index 000000000..3d78e5787 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountregister/api/AccountRegisterApiResource.java @@ -0,0 +1,299 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.accountregister.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.Date; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Account register for one date: which accounts were opened and which were closed that day, with + * each account's balance as it stood on that date alongside what it holds now. + * + * The dated balance is read from the running balance stamped on the last transaction up to that + * date, rather than recomputed from the account's current figure — a current balance tells you + * nothing about what the account held three weeks ago. + */ +@Path("/v1/accountregister") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class AccountRegisterApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + @GET + public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) { + this.context.authenticatedUser(); + + final LocalDate asOn; + if (dateParam != null && !dateParam.isBlank()) { + try { + asOn = LocalDate.parse(dateParam); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid", + "Date must be in yyyy-MM-dd format."); + } + } else { + asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + } + + final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase(); + final List> rows = new ArrayList<>(); + + if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) { + rows.addAll(depositAccounts(asOn, want)); + } + if (matches(want, "SHARE")) { + rows.addAll(shareAccounts(asOn)); + } + + // Opened and closed counts come from the rows themselves rather than separate COUNT queries, + // so the totals can never disagree with the list they summarise. + int opened = 0; + int closed = 0; + BigDecimal balanceOnDate = BigDecimal.ZERO; + BigDecimal currentBalance = BigDecimal.ZERO; + for (final Map r : rows) { + if (Boolean.TRUE.equals(r.get("openedOnDate"))) { + opened++; + } + if (Boolean.TRUE.equals(r.get("closedOnDate"))) { + closed++; + } + balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate"))); + currentBalance = currentBalance.add(dec(r.get("currentBalance"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("date", asOn.toString()); + out.put("section", want); + out.put("accounts", rows); + out.put("count", rows.size()); + out.put("openedCount", opened); + out.put("closedCount", closed); + out.put("totalBalanceOnDate", balanceOnDate); + out.put("totalCurrentBalance", currentBalance); + return gson.toJson(out); + } + + private static boolean matches(final String want, final String section) { + return "ALL".equals(want) || want.equals(section); + } + + private static BigDecimal dec(final Object o) { + return o instanceof BigDecimal b ? b : BigDecimal.ZERO; + } + + /** + * Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told + * apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring. + * + * Accounts submitted after the date are excluded — on that date they did not exist, and listing + * them would make a register of a past day describe a present one. + */ + private List> depositAccounts(final LocalDate asOn, final String want) { + final Date date = Date.valueOf(asOn); + + final StringBuilder typeFilter = new StringBuilder(); + if (!"ALL".equals(want)) { + final int type = switch (want) { + case "SAVINGS" -> 100; + case "FD" -> 200; + case "RD" -> 300; + default -> 0; + }; + if (type > 0) { + typeFilter.append(" AND sa.deposit_type_enum = ").append(type); + } + } + + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum, + sa.activatedon_date, sa.closedon_date, sa.submittedon_date, + sa.account_balance_derived, + c.display_name AS client_name, + p.name AS product_name, + (SELECT t.running_balance_derived + FROM m_savings_account_transaction t + WHERE t.savings_account_id = sa.id + AND COALESCE(t.is_reversed, false) = false + AND t.transaction_date <= ? + ORDER BY t.transaction_date DESC, t.id DESC + LIMIT 1) AS balance_on_date + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_savings_product p ON p.id = sa.product_id + WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?) + """ + typeFilter + """ + ORDER BY sa.deposit_type_enum, sa.account_no + """, date, date); + + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + final int depositType = num(r.get("deposit_type_enum")); + m.put("accountId", r.get("id")); + m.put("accountNo", r.get("account_no")); + m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS"); + m.put("clientName", r.get("client_name")); + m.put("productName", r.get("product_name")); + m.put("status", savingsStatus(num(r.get("status_enum")))); + m.put("openedOn", str(r.get("activatedon_date"))); + m.put("closedOn", str(r.get("closedon_date"))); + m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn)); + m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn)); + // No transaction up to that date means the account had nothing in it yet, not that the + // balance is unknown. + m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date")); + m.put("currentBalance", r.get("account_balance_derived") == null + ? BigDecimal.ZERO : r.get("account_balance_derived")); + out.add(m); + } + return out; + } + + /** + * Share accounts. Their transactions carry no running balance the way savings ones do, but they + * are individually dated, so the holding on a given date is the shares purchased up to that + * date less those redeemed — which is a real dated figure, not the current one relabelled. + */ + private List> shareAccounts(final LocalDate asOn) { + final Date date = Date.valueOf(asOn); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date, + sa.total_approved_shares, + c.display_name AS client_name, + p.name AS product_name, + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price + WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price) + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600)) AS share_value, + -- Purchases less redemptions dated on or before the register's date. Charge + -- rows (type 700) are excluded: an entry fee is not share capital. Only + -- approved purchases count (status 300) — a rejected application leaves its + -- row behind still flagged active, and counting it credited a member with + -- shares they were never issued. + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price + WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price) + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600) + AND t.transaction_date <= ?) AS value_on_date, + (SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares + WHEN t.type_enum = 600 THEN -t.total_shares + ELSE 0 END), 0) + FROM m_share_account_transactions t + WHERE t.account_id = sa.id AND t.is_active = true + AND t.status_enum = 300 + AND t.type_enum IN (500, 600) + AND t.transaction_date <= ?) AS shares_on_date + FROM m_share_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_share_product p ON p.id = sa.product_id + WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?) + ORDER BY sa.account_no + """, date, date, date); + + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("accountId", r.get("id")); + m.put("accountNo", r.get("account_no")); + m.put("section", "SHARE"); + m.put("clientName", r.get("client_name")); + m.put("productName", r.get("product_name")); + m.put("status", shareStatus(num(r.get("status_enum")))); + m.put("openedOn", str(r.get("activated_date"))); + m.put("closedOn", str(r.get("closed_date"))); + m.put("openedOnDate", sameDay(r.get("activated_date"), asOn)); + m.put("closedOnDate", sameDay(r.get("closed_date"), asOn)); + m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date")); + m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value")); + // Shares as at the date, not the approved figure — an account can hold fewer than were + // approved once some have been redeemed. + m.put("sharesHeld", r.get("shares_on_date")); + out.add(m); + } + return out; + } + + private static boolean sameDay(final Object dbDate, final LocalDate asOn) { + return dbDate != null && asOn.toString().equals(dbDate.toString()); + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } + + private static int num(final Object o) { + return o instanceof Number n ? n.intValue() : 0; + } + + private static String savingsStatus(final int status) { + return switch (status) { + case 100 -> "SUBMITTED"; + case 200 -> "APPROVED"; + case 300 -> "ACTIVE"; + case 400 -> "WITHDRAWN"; + case 500 -> "REJECTED"; + case 600 -> "CLOSED"; + case 700 -> "TRANSFER IN PROGRESS"; + case 800 -> "TRANSFER ON HOLD"; + case 900 -> "PREMATURELY CLOSED"; + default -> "UNKNOWN"; + }; + } + + private static String shareStatus(final int status) { + return switch (status) { + case 100 -> "SUBMITTED"; + case 200 -> "APPROVED"; + case 300 -> "ACTIVE"; + case 400 -> "WITHDRAWN"; + case 500 -> "REJECTED"; + case 600 -> "CLOSED"; + default -> "UNKNOWN"; + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java index 6792f9e6b..014ba7d8a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlFundTransferEvaluationService.java @@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** @@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Slf4j -@Transactional +// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not +// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer. +@Transactional(propagation = Propagation.REQUIRES_NEW) public class AmlFundTransferEvaluationService { private static final int STRUCTURING_WINDOW_DAYS = 30; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 862213e83..843a436d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +/** + * REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a + * transaction that has already succeeded. Joining the caller's transaction would let a failed + * alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at + * commit time - and the callers' catch blocks cannot rescue that, because the damage is done to + * the shared transaction rather than thrown to them. A private transaction keeps AML failure + * contained to AML. + */ @Service @RequiredArgsConstructor -@Transactional +@Transactional(propagation = Propagation.REQUIRES_NEW) public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { private static final int MEDIUM_RISK = 2; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java new file mode 100644 index 000000000..ae56cd0b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -0,0 +1,477 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.branchday.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Branch day register — the formal opening and closing of a branch's business day. + * + * A day that was never closed is the thing this exists to surface: while it stays open its balance + * cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash + * difference on that date goes unnoticed. The register records who opened and closed each day, and + * lists the ones still hanging. + * + * Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it + * was left open, so "closed that evening" and "closed nine days later" stay distinguishable. + */ +@Path("/v1/branchday") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class BranchDayRegisterApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ── GET /v1/branchday ── the register itself ────────────────────────────── + + /** + * The register for one office, newest first. Defaults to the caller's own office so a branch + * user sees their own branch without having to know its id. + */ + @GET + public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT r.id, r.office_id, o.name AS office_name, r.business_date, + r.opened_by_name, r.opened_at, r.opening_cash, + r.closed_by_name, r.closed_at, r.closing_cash, + r.book_cash, r.cash_difference, + r.force_closed, r.force_close_reason, r.remarks + FROM m_branch_day_register r + LEFT JOIN m_office o ON o.id = r.office_id + WHERE r.office_id = ? + ORDER BY r.business_date DESC + LIMIT ? + """, officeId, limit); + + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("canOperate", canOperateDay(currentUser)); + result.put("days", mapDays(rows)); + result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); + result.put("openDay", findOpenDay(officeId)); + return gson.toJson(result); + } + + // ── GET /v1/branchday/missed ── days left open ──────────────────────────── + + /** + * Business days that were opened and never closed, oldest first — the ones that need attention + * most. Today's own open day is excluded: it is open because the day is still running, which is + * not a missed close. + */ + @GET + @Path("missed") + public String missed(@QueryParam("officeId") final Long officeIdParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT r.id, r.office_id, o.name AS office_name, r.business_date, + r.opened_by_name, r.opened_at, r.opening_cash, + r.closed_by_name, r.closed_at, r.closing_cash, + r.book_cash, r.cash_difference, + r.force_closed, r.force_close_reason, r.remarks + FROM m_branch_day_register r + LEFT JOIN m_office o ON o.id = r.office_id + WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ? + ORDER BY r.business_date ASC + """, officeId, Date.valueOf(today)); + + final List> days = mapDays(rows); + // Age in days is what makes the list actionable — a day open since last week is a different + // problem from one open since yesterday. + for (final Map d : days) { + final Object bd = d.get("businessDate"); + d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today)); + } + + return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days, + "count", days.size())); + } + + // ── GET /v1/branchday/close-preview ────────────────────────────────────── + + /** + * What the day should close at, with the arithmetic that produces it, plus the denomination + * total the cashier actually keyed in during the day. + * + * Exists so nobody has to work the figure out by hand or type it from memory: the screen shows + * where every line came from, and offers the counted total as the closing figure. A day is + * still closed on what someone physically counted — this only removes the guesswork about what + * that count is supposed to match. + */ + @GET + @Path("close-preview") + public String closePreview(@QueryParam("officeId") final Long officeIdParam, + @QueryParam("businessDate") final String businessDateParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + + final LocalDate businessDate; + if (businessDateParam != null && !businessDateParam.isBlank()) { + try { + businessDate = LocalDate.parse(businessDateParam); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid", + "Business date must be in yyyy-MM-dd format."); + } + } else { + final Map open = findOpenDay(officeId); + if (open == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.not.open", + "No business day is open for this branch."); + } + businessDate = LocalDate.parse(String.valueOf(open.get("businessDate"))); + } + + final Map result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate)); + result.put("officeId", officeId); + result.put("businessDate", businessDate.toString()); + + // What the cashier's own denomination entries add up to for the day. Where this and the + // expected figure agree, the day tallies and the closing figure is not in question. + final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination + WHERE txn_date = ? AND txn_type = 'DEPOSIT' + """, BigDecimal.class, Date.valueOf(businessDate))); + final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination + WHERE txn_date = ? AND txn_type = 'WITHDRAWAL' + """, BigDecimal.class, Date.valueOf(businessDate))); + result.put("denominationIn", countedIn); + result.put("denominationOut", countedOut); + + return gson.toJson(result); + } + + // ── POST /v1/branchday/open ─────────────────────────────────────────────── + + @POST + @Path("open") + @Consumes(MediaType.APPLICATION_JSON) + public String openDay(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + requireDayAuthority(currentUser); + + final var root = this.fromJsonHelper.parse(body); + final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); + final LocalDate businessDate = extractDate(root, "businessDate", + LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); + + // A branch runs one day at a time. Opening a second while the first is still open is how a + // day gets stranded, so the previous one has to be closed first. + final Map open = findOpenDay(officeId); + if (open != null) { + final Object existing = open.get("businessDate"); + if (businessDate.toString().equals(String.valueOf(existing))) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.open", + "Business day " + businessDate + " is already open for this branch."); + } + throw new PlatformDataIntegrityException("error.msg.branchday.previous.open", + "Business day " + existing + " is still open. Close it before opening " + businessDate + "."); + } + + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + Integer.class, officeId, Date.valueOf(businessDate)); + if (exists != null && exists > 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.closed", + "Business day " + businessDate + " has already been opened and closed for this branch."); + } + + // Yesterday's closing is today's opening unless the caller states otherwise, so the carried + // figure does not have to be retyped and cannot drift. + final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate); + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_branch_day_register + (office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed) + VALUES (?,?,?,?,?,?,false) + RETURNING id + """, + Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(), + Timestamp.from(Instant.now()), carried); + + return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(), + "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN")); + } + + // ── POST /v1/branchday/close ────────────────────────────────────────────── + + @POST + @Path("close") + @Consumes(MediaType.APPLICATION_JSON) + public String closeDay(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + requireDayAuthority(currentUser); + + final var root = this.fromJsonHelper.parse(body); + final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); + final LocalDate businessDate = extractDate(root, "businessDate", null); + if (businessDate == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.required", + "Which business day is being closed?"); + } + final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); + + final List> found = jdbcTemplate.queryForList( + "SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + officeId, Date.valueOf(businessDate)); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.not.open", + "Business day " + businessDate + " was never opened for this branch."); + } + if (found.get(0).get("closed_at") != null) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.closed", + "Business day " + businessDate + " is already closed."); + } + + // Closing a past date is allowed — otherwise a day left open could never be settled at all — + // but it must say why it was left open, and it is stamped as a force close. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final boolean isLate = businessDate.isBefore(today); + if (isLate && (forceReason == null || forceReason.isBlank())) { + throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required", + "Business day " + businessDate + " is being closed late. Record why it was left open."); + } + + final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); + final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; + + jdbcTemplate.update(""" + UPDATE m_branch_day_register + SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?, + book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? + WHERE office_id = ? AND business_date = ? + """, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash, + bookCash, difference, isLate, isLate ? forceReason : null, remarks, + officeId, Date.valueOf(businessDate)); + + final Map res = new LinkedHashMap<>(); + res.put("officeId", officeId); + res.put("businessDate", businessDate.toString()); + res.put("status", "CLOSED"); + res.put("closingCash", closingCash); + res.put("bookCash", bookCash); + res.put("cashDifference", difference); + res.put("forceClosed", isLate); + return gson.toJson(res); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private List> mapDays(final List> rows) { + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("officeId", r.get("office_id")); + m.put("officeName", r.get("office_name")); + m.put("businessDate", str(r.get("business_date"))); + m.put("openedByName", r.get("opened_by_name")); + m.put("openedAt", str(r.get("opened_at"))); + m.put("openingCash", r.get("opening_cash")); + m.put("closedByName", r.get("closed_by_name")); + m.put("closedAt", str(r.get("closed_at"))); + m.put("closingCash", r.get("closing_cash")); + m.put("bookCash", r.get("book_cash")); + m.put("cashDifference", r.get("cash_difference")); + m.put("forceClosed", r.get("force_closed")); + m.put("forceCloseReason", r.get("force_close_reason")); + m.put("remarks", r.get("remarks")); + m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED"); + out.add(m); + } + return out; + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } + + /** The branch's currently open day, or null when none is open. */ + private Map findOpenDay(final Long officeId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash + FROM m_branch_day_register + WHERE office_id = ? AND closed_at IS NULL + ORDER BY business_date ASC LIMIT 1 + """, officeId); + if (rows.isEmpty()) { + return null; + } + final Map r = rows.get(0); + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("businessDate", str(r.get("business_date"))); + m.put("openedByName", r.get("opened_by_name")); + m.put("openedAt", str(r.get("opened_at"))); + m.put("openingCash", r.get("opening_cash")); + return m; + } + + /** The most recent closing figure before this date, so it carries forward as the opening. */ + private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) { + final List rows = jdbcTemplate.query(""" + SELECT closing_cash FROM m_branch_day_register + WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL + ORDER BY business_date DESC LIMIT 1 + """, (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before)); + return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0); + } + + /** + * Cash the books say the branch should be holding at the end of that date: what came in as + * approved cash deposits less what went out as approved cash withdrawals. Deliberately derived + * here and then stored on the row, so the figure the day was closed against survives any later + * reversal. + */ + private Map bookBreakdown(final Long officeId, final LocalDate businessDate) { + final Date date = Date.valueOf(businessDate); + + // The figure recorded when this very day was opened — not the previous day's closing. + // Deriving it from history alone ignored an opening balance keyed in by hand, which is + // exactly what happens on the first day a branch is put on the system: the day was opened + // with 20,000 in the drawer and the closing arithmetic started from zero. + final BigDecimal ownOpening = jdbcTemplate.query( + "SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); + final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); + + final BigDecimal deposits = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request + WHERE status = 'APPROVED' + AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? + AND (office_id = ? OR office_id IS NULL) + """, BigDecimal.class, date, officeId)); + + final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request + WHERE status = 'APPROVED' + AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? + """, BigDecimal.class, date)); + + // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. + final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer + WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, date)); + + // Cash a cashier settled back to the vault at the end of the day. + final BigDecimal settledOut = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement + WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, date)); + + final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut); + + final Map out = new LinkedHashMap<>(); + out.put("openingCash", opening); + out.put("deposits", deposits); + out.put("vaultIn", vaultIn); + out.put("withdrawals", withdrawals); + out.put("settledOut", settledOut); + out.put("expectedCash", expected); + return out; + } + + private static BigDecimal zero(final BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private Long resolveOffice(final Long requested, final AppUser user) { + if (requested != null) { + return requested; + } + return user.getOffice() != null ? user.getOffice().getId() : 1L; + } + + private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) { + final String raw = this.fromJsonHelper.extractStringNamed(field, root); + if (raw == null || raw.isBlank()) { + return fallback; + } + try { + return LocalDate.parse(raw); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid", + "Business date must be in yyyy-MM-dd format."); + } + } + + /** + * Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the + * same authority as the manager here by design — a day left open because the only manager was + * away is exactly the situation this register exists to prevent. + * + * A cashier is excluded: the cashier works inside the day and must not be the one who declares + * it closed. + */ + private boolean canOperateDay(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk")); + } + + private void requireDayAuthority(final AppUser user) { + if (!canOperateDay(user)) { + throw new PlatformDataIntegrityException("error.msg.branchday.forbidden", + "Only a Clerk, Head Cashier or Bank Manager can open or close a business day."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index f0cb31fce..95e444476 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -32,6 +32,179 @@ public class CashReportApiResource { private final CashierRoleHelper cashierRoleHelper; private final Gson gson = new Gson(); + /** + * Day book — every voucher raised on one date, in one list, newest first. + * + * Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR) + * and the clerk disbursement register (DR, or CR for a collection). Each row carries its + * voucher number, side, amount, account, party and status, so the cashier can reconcile a day + * without opening three separate screens. + * + * Dated on the business date the voucher belongs to rather than the row's creation timestamp: + * deposits and withdrawals carry an explicit txn_date, and the disbursement register — which + * has no such column — falls back to the date it was submitted on. + */ + @GET + @Path("day-book") + public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side, + @QueryParam("status") final String status) { + + final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + + final List> rows = new ArrayList<>(); + + // Each register is left-joined to the verification table and to an attachment count, so a + // row already carries its physical-check verdict and whether a slip was scanned in — the + // screen never has to fire a follow-up call per voucher. + final String verifyJoin = """ + LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = r.voucher_no + """; + final String verifyCols = """ + vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks, + vv.verified_by_name, vv.verified_at, + vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at, + COALESCE(va.n, 0) AS attachment_count + """; + + // Cashier deposits — money in, so the credit side. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type, + r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, + r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_cashier_deposit_request r + """ + verifyJoin + """ + WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Clerk withdrawals — money out, so the debit side. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type, + r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, + r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_withdrawal_request r + """ + verifyJoin + """ + WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a + // bank transfer moves it out (DR) — matching how the voucher itself was numbered. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, + CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side, + r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type, + r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name, + r.description, r.status, r.clerk_name AS handled_by, + r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_disbursement_request r + """ + verifyJoin + """ + WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be + // ordered as one result set, and the row count for a single day is small. + final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); + final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase(); + + final List> filtered = new ArrayList<>(); + BigDecimal creditTotal = BigDecimal.ZERO; + BigDecimal debitTotal = BigDecimal.ZERO; + + for (final Map row : rows) { + if (sideFilter != null && !sideFilter.equals(row.get("side"))) { + continue; + } + if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) { + continue; + } + filtered.add(row); + + // Only approved money is real money: a pending or rejected voucher must not inflate the + // day's totals, even though it still belongs in the listing. + if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) { + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount != null) { + if ("CR".equals(row.get("side"))) { + creditTotal = creditTotal.add(amount); + } else { + debitTotal = debitTotal.add(amount); + } + } + } + } + + filtered.sort((a, b) -> { + final Object left = b.get("submittedAt"); + final Object right = a.get("submittedAt"); + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.toString().compareTo(right.toString()); + }); + + final Map result = new LinkedHashMap<>(); + result.put("date", txnDate); + result.put("rows", filtered); + result.put("count", filtered.size()); + result.put("creditTotal", creditTotal); + result.put("debitTotal", debitTotal); + result.put("netTotal", creditTotal.subtract(debitTotal)); + return gson.toJson(result); + } + + private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { + final Map row = new LinkedHashMap<>(); + row.put("voucherNo", rs.getString("voucher_no")); + row.put("side", rs.getString("side")); + row.put("kind", rs.getString("kind")); + row.put("accountNo", rs.getString("account_no")); + row.put("accountType", rs.getString("account_type")); + row.put("party", rs.getString("party")); + row.put("amount", rs.getBigDecimal("amount")); + row.put("paymentTypeName", rs.getString("payment_type_name")); + row.put("description", rs.getString("description")); + row.put("status", rs.getString("status")); + row.put("handledBy", rs.getString("handled_by")); + row.put("approvedBy", rs.getString("approved_by")); + row.put("txnId", rs.getString("txn_id")); + + // Physical-slip check. verifyStatus is null until someone actually looks at the paper. + row.put("verifyStatus", rs.getString("verify_status")); + row.put("slipAmount", rs.getBigDecimal("slip_amount")); + row.put("verifyRemarks", rs.getString("verify_remarks")); + row.put("verifiedByName", rs.getString("verified_by_name")); + final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at"); + row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString()); + + // How the exception ended, if it has. Without this the day book keeps showing a closed + // exception as though it were still open — the flag never clears on screen. + row.put("resolutionStatus", rs.getString("resolution_status")); + row.put("resolutionNotes", rs.getString("resolution_notes")); + row.put("resolvedByName", rs.getString("resolved_by_name")); + final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at"); + row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString()); + row.put("attachmentCount", rs.getInt("attachment_count")); + // Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped + // form the browser cannot parse reliably. + final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at"); + final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at"); + row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString()); + row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString()); + return row; + } + @GET @Path("closing") public String closing(@QueryParam("date") String date) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java new file mode 100644 index 000000000..4db4f4227 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -0,0 +1,754 @@ +package org.apache.fineract.portfolio.cashtransfer.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +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 jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Share Advance Collection API — cash taken from a member BEFORE any share account exists. + * + * The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that + * flow. + * + *

+ * 1. Collect — {@code POST /v1/shareadvancecollection} + * + *

+ *   Dr  Cash at Vault      (SHARES_REFERENCE)  = amount
+ *   Cr  Share Sundry       (SHARES_SUSPENSE)   = amount
+ * 
+ * + * The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in + * {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet. + * + *

+ * 2. Apply — {@code POST /v1/shareadvancecollection/{id}/apply} + * + *

+ *   Dr  Share Sundry       (SHARES_SUSPENSE)   = everything collected
+ *   Cr  Share Capital      (SHARES_EQUITY)     = share amount
+ *   Cr  Income from Fees   (INCOME_FROM_FEES)  = charge amount  (only when a charge applies)
+ *   Cr  Savings            (via deposit cmd)   = over-payment    (only when the member paid more)
+ * 
+ * + * Applying clears the liability, issues the share capital, marks the account's charges paid and sets + * {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views. + * Account creation, approval and activation post no GL of their own — all share accounting happens here. + * + *

+ * When the cash does not match + * + *

+ * A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be + * argued with at the window: + * + *

    + *
  • Short — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt + * and applies the receipts together: one settlement, several receipts, all marked applied.
  • + *
  • Over — allowed, and the difference is credited to the member's linked savings account through Fineract's + * own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute + * back to them.
  • + *
+ */ +@Path("/v1/shareadvancecollection") +@Component +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareAdvanceCollectionApiResource { + + // ── acc_gl_journal_entry.type_enum ───────────────────────────────────── + // Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr). + private static final int DEBIT = 2; + private static final int CREDIT = 1; + + // ── CashAccountsForShares financial_account_type ─────────────────────── + private static final int FA_SHARES_REFERENCE = 1; + private static final int FA_SHARES_SUSPENSE = 2; + private static final int FA_INCOME_FROM_FEES = 3; + private static final int FA_SHARES_EQUITY = 4; + + // ── acc_product_mapping.product_type for shares ──────────────────────── + private static final int PRODUCT_TYPE_SHARES = 4; + + // ── m_share_account.status_enum ─────────────────────────────────────── + private static final int STATUS_ACTIVE = 300; + + // ── GL account the cashier deposits share money into ────────────────── + private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new Gson(); + + // ══════════════════════════════════════════════════════════════════════ + // POST — Take cash from a member with no share account yet + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + public Response collect(final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final Long clientId = getLong(json, "clientId"); + final BigDecimal amount = getBigDecimal(json, "amount"); + final String note = sanitiseNote(getString(json, "note")); + final LocalDate entryDate = parseDate(getString(json, "transactionDate")); + + if (clientId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required."); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero."); + } + + final Map client = loadClient(clientId); + final Long officeId = toLong(client.get("office_id")); + final String clientName = (String) client.get("display_name"); + + // The share product drives the GL mapping. Collection happens before an account exists, + // so the product is supplied by the caller or defaults to the only mapped share product. + final Long productId = resolveProductId(getLong(json, "productId")); + final String currencyCode = resolveCurrency(productId); + + final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)"); + final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHADV-" + System.nanoTime(); + final String receiptNo = nextReceiptNo(); + + final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : ""); + + insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc); + insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode, + "Share advance held in Sundry | Receipt " + receiptNo); + + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId, + currentUser.getUsername(), note); + + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class, + receiptNo); + + final Map out = new LinkedHashMap<>(); + out.put("collectionId", id); + out.put("receiptNo", receiptNo); + out.put("clientId", clientId); + out.put("clientName", clientName); + out.put("amount", amount); + out.put("currency", currencyCode); + out.put("status", "UNAPPLIED"); + out.put("glTransactionId", txnId); + out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created."); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — Unapplied receipts, optionally for one client + // ══════════════════════════════════════════════════════════════════════ + @GET + public Response listUnapplied(@QueryParam("clientId") final Long clientId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," + + " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note " + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "); + if (clientId != null) { + sql.append(" AND c.client_id = ").append(clientId); + } + sql.append(" ORDER BY c.collection_date, c.id"); + + final List> rows = jdbcTemplate.queryForList(sql.toString()); + final Map out = new LinkedHashMap<>(); + out.put("unappliedCollections", rows); + out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — One unapplied receipt by the voucher number quoted by the member + // ══════════════════════════════════════════════════════════════════════ + // The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001: + // as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict + // firewall with a 400 before the request reaches this resource. The older + // /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes. + @GET + @Path("/by-voucher") + public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo, + @QueryParam("clientId") final Long clientIdHint) { + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required", + "A voucher or receipt number is required."); + } + return findByVoucher(voucherNo, clientIdHint); + } + + @GET + @Path("/by-voucher/{voucherNo}") + public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) { + this.context.authenticatedUser(); + + // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. + final List> rows = jdbcTemplate + .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + + " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id " + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); + + if (rows.isEmpty()) { + // Cash is normally taken through the cashier's Transaction Entry deposit into the Share + // Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a + // collection so the member's voucher resolves here. + final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint); + if (adopted != null) { + return Response.ok(gson.toJson(adopted)).build(); + } + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher " + + voucherNo + ". Check the number, or confirm the deposit has been approved by a manager."); + } + + final Map row = rows.get(0); + if (!"UNAPPLIED".equals(row.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again."); + } + return Response.ok(gson.toJson(row)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST — Apply a receipt to an activated share account + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{collectionId}/apply") + @Transactional + public Response apply(@PathParam("collectionId") final Long collectionId, final String body) { + + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + + final JsonObject json = parseBody(body); + final Long shareAccountId = getLong(json, "shareAccountId"); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required."); + } + + // A member who pays in instalments has more than one receipt against the same application: + // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they + // are applied together as one settlement. The path receipt leads; any others come in the + // body. + final List collectionIds = new java.util.ArrayList<>(); + collectionIds.add(collectionId); + for (final Long additionalId : getLongList(json, "additionalCollectionIds")) { + if (!collectionIds.contains(additionalId)) { + collectionIds.add(additionalId); + } + } + + final List> collections = new java.util.ArrayList<>(); + for (final Long id : collectionIds) { + final Map row = loadCollection(id); + if (!"UNAPPLIED".equals(row.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied", + "Receipt " + row.get("receipt_no") + " is already " + row.get("status") + "."); + } + collections.add(row); + } + + final Map coll = collections.get(0); + + final Map acct = loadAccount(shareAccountId); + + // Every receipt belongs to the member: applying one to someone else's account would move + // one member's money into another's share capital. Checked per receipt, since a settlement + // can now carry several. + final Long acctClientId = toLong(acct.get("client_id")); + for (final Map row : collections) { + if (!toLong(row.get("client_id")).equals(acctClientId)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no") + + " belongs to a different member than share account " + acct.get("account_no") + "."); + } + } + + final int statusEnum = toInt(acct.get("status_enum")); + if (statusEnum != STATUS_ACTIVE) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active", + "Share account " + acct.get("account_no") + " must be Active before a collection can be applied."); + } + if (Boolean.TRUE.equals(acct.get("collection_applied"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled", + "Share account " + acct.get("account_no") + " has already been settled."); + } + + // Share value the account was opened for, and any entry fee attached to it. + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + + BigDecimal receiptAmount = BigDecimal.ZERO; + final StringBuilder receiptNos = new StringBuilder(); + for (final Map row : collections) { + receiptAmount = receiptAmount.add((BigDecimal) row.get("amount")); + if (receiptNos.length() > 0) { + receiptNos.append(" + "); + } + receiptNos.append(row.get("receipt_no")); + } + + // Short is blocked: share capital cannot be part-issued — the member either bought those + // shares or did not — so settling for less would leave the account short by an amount owed + // by nobody. The clerk collects the balance on another receipt and applies both together. + if (receiptAmount.compareTo(required) < 0) { + final BigDecimal shortfall = required.subtract(receiptAmount); + throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short", + "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires " + + required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall + + " from the member, then apply both receipts together."); + } + + // Over-payment is allowed through rather than turning a member away over a rounded-up note. + // The difference is credited to their linked savings account below. + final BigDecimal excessAmount = receiptAmount.subtract(required); + final Long savingsAccountId = toLong(acct.get("savings_account_id")); + if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings", + "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + + " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings " + + "account, but none is linked to this share account. Link one, or collect the exact amount."); + } + + final Long officeId = toLong(acct.get("office_id")); + final Long productId = toLong(acct.get("product_id")); + final String currencyCode = (String) acct.get("currency_code"); + + final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate entryDate = parseDate(getString(json, "transactionDate")); + final String txnId = "SHAPP-" + System.nanoTime(); + final String accountNo = (String) acct.get("account_no"); + + // Dr Sundry for everything collected, including any over-payment — the liability those + // receipts created is cleared in full, in one leg, so nothing is left behind in suspense. + insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); + + // Cr Share Capital for the share value. + insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share capital issued for " + accountNo); + + // Cr Income from Fees for the entry fee, when one was charged. + if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + // Resolved from the share product's "Income from Fees" mapping, which points at the + // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + + jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); + } + + // The over-payment goes back to the member as a savings deposit rather than a bare journal + // entry, so it lands in their passbook and running balance the way any counter deposit + // would. Posted through Fineract's own deposit command, which writes its own Cr Savings + // Control leg — the Dr side is the Sundry debit above, which covered the whole receipt. + Long excessSavingsTransactionId = null; + if (excessAmount.compareTo(BigDecimal.ZERO) > 0) { + excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate, + "Excess on share application receipt " + receiptNos + " for share account " + accountNo); + } + + // Stamp the originating cashier deposits so the same vouchers cannot be applied twice. + for (final Map row : collections) { + if (row.get("voucher_no") != null) { + jdbcTemplate.update( + "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " + + " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO); + } + } + + // Every receipt in this settlement is marked applied, not just the one in the path — they + // were all consumed by it, and an unmarked one would still show as money awaiting use. + for (final Long id : collectionIds) { + jdbcTemplate.update( + "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); + } + + // Reveals the purchase transaction in the passbook and transactions views. + jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); + + final Map out = new LinkedHashMap<>(); + out.put("collectionId", collectionId); + out.put("collectionIds", collectionIds); + out.put("receiptNo", receiptNos.toString()); + out.put("shareAccountId", shareAccountId); + out.put("accountNo", accountNo); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalApplied", required); + out.put("totalCollected", receiptAmount); + out.put("glTransactionId", txnId); + out.put("status", "APPLIED"); + // Told at the counter, while the member is still there, rather than left to be noticed on + // a statement later. + out.put("excessAmount", excessAmount); + if (excessSavingsTransactionId != null) { + out.put("excessSavingsTransactionId", excessSavingsTransactionId); + out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to " + + "their savings account."); + } + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** + * Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection. + * + * The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager, + * so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup + * so the voucher resolves and can be applied. + * + * A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the + * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which + * is what keeps the money tied to the right member. + * + * @return the newly created collection row, or null when no approved Sundry deposit matches. + */ + private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) { + final List> deposits = jdbcTemplate + .queryForList( + "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," + + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + + " FROM m_cashier_deposit_request d" + + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", + voucherNo, SHARE_SUNDRY_ACCOUNT_NO); + + if (deposits.isEmpty()) { + return null; + } + final Map dep = deposits.get(0); + + if (toLong(dep.get("share_account_id")) != null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " has already been applied to a share account."); + } + + // Prefer the member recorded on the deposit; fall back to the account being settled. + final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint; + if (clientId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client", + "Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member," + + " so it cannot be matched. Re-enter the deposit against the member."); + } + + final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId); + final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR"; + final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final String receiptNo = nextReceiptNo(); + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency, + dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), + "Adopted from cashier deposit voucher " + voucherNo); + + return jdbcTemplate.queryForMap( + "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", + receiptNo); + } + + private Long loadClientOfficeId(final Long clientId) { + return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId); + } + + /** Share value the account was opened for: the pending/approved purchase transaction. */ + private BigDecimal shareValueOf(final Long shareAccountId) { + final BigDecimal total = jdbcTemplate + .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " + + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + /** Entry fee still outstanding on the account, zero when no charge was attached. */ + private BigDecimal outstandingChargeOf(final Long shareAccountId) { + // Driven by the money still owed rather than the is_paid_derived flag alone: accounts + // created before the flat-charge fix carry the flag set to true while the full amount is + // still outstanding, and those charges must still be settled by the collection. + final BigDecimal total = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge " + + " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0", + BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + private Map loadCollection(final Long id) { + try { + return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", + "Share advance collection " + id + " does not exist."); + } + } + + private Map loadClient(final Long clientId) { + try { + return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist."); + } + } + + private Map loadAccount(final Long shareAccountId) { + try { + return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, " + + " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id " + + " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found", + "Share account " + shareAccountId + " does not exist."); + } + } + + /** + * Uses the caller's product when given, else the single mapped share product. + * + * The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are + * not share products (stale rows left by deleted products), and counting those would make auto-detection fail even + * when only one real share product is mapped. + */ + private Long resolveProductId(final Long requested) { + if (requested != null) { + return requested; + } + final List ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m " + + " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES); + if (ids.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped", + "No share product has GL mappings configured."); + } + if (ids.size() > 1) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required", + "productId is required: " + ids.size() + " share products have GL mappings."); + } + return ids.get(0); + } + + private String resolveCurrency(final Long productId) { + try { + final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class, + productId); + return code == null ? "INR" : code; + } catch (EmptyResultDataAccessException e) { + return "INR"; + } + } + + private Long requireGl(final Long productId, final int financialAccountType, final String label) { + final Long glId = resolveGl(productId, financialAccountType); + if (glId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } + + private Long resolveGl(final Long productId, final int financialAccountType) { + try { + return jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + } catch (IncorrectResultSizeDataAccessException e) { + return null; + } + } + + private String nextReceiptNo() { + final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class); + return String.format("SHADV%06d", seq == null ? 1 : seq); + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in + * the tenant's own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required."); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (Exception e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON."); + } + } + + private LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + } + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid", + "transactionDate must be in YYYY-MM-DD format. Received: " + raw); + } + } + + private String sanitiseNote(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + final String trimmed = raw.trim(); + return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed; + } + + private String getString(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null; + } + + private Long getLong(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; + } + + private BigDecimal getBigDecimal(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null; + } + + /** Further receipts making up one settlement. Empty when the caller sent a single receipt. */ + private List getLongList(final JsonObject json, final String field) { + final List values = new java.util.ArrayList<>(); + if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) { + return values; + } + for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) { + if (!element.isJsonNull()) { + values.add(element.getAsLong()); + } + } + return values; + } + + /** + * Credits an over-payment to the member's savings account. + * + * Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in + * the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry + * would move the balance in the ledger while leaving the member's own account history silent about it. + */ + private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()) + .build(); + return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId(); + } + + /** + * The payment type this deposit is recorded under. + * + * Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in + * the opposite direction — an internal transfer between a share application and the member's savings, not cash + * over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row. + */ + private Long resolveExcessPaymentTypeId() { + final List byName = jdbcTemplate.queryForList( + "SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class); + if (!byName.isEmpty()) { + return byName.get(0); + } + final List any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class); + if (any.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type", + "No payment type is configured, so the over-payment cannot be credited to the member's savings account."); + } + return any.get(0); + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } + + private int toInt(final Object o) { + return o instanceof Number ? ((Number) o).intValue() : 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java index 5e7d9c977..bca22f22d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java @@ -375,8 +375,20 @@ public class ShareCollectionApiResource { userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), - now.atOffset(ZoneOffset.UTC), - now.atOffset(ZoneOffset.UTC)); + toUtc(now), + toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in + * the tenant's own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); } private Map glEntryMap(final String account, final String type, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java new file mode 100644 index 000000000..f80c7fc50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java @@ -0,0 +1,601 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.cashtransfer.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.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 jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Settles a share application out of the member's own savings account. + * + * The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks + * in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks + * with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly: + * + *
+ *   Dr  Savings Control     (share value + entry fee)
+ *       Cr  Share Capital       (share value)
+ *       Cr  Entry Fee Income    (charge, when one is attached)
+ * 
+ * + * No Sundry leg, because nothing is ever held — the debit and the share issue happen together. + * + * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval + * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair + * of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves. + * + * The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and + * running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own + * Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and + * replaced with the share-capital pair above. + */ +@Path("/v1/sharesavingsdebit") +@Component +@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareSavingsDebitRequestApiResource { + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int CREDIT = 1; + private static final int DEBIT = 2; + + // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource. + private static final int FA_INCOME_FROM_FEES = 3; + private static final int FA_SHARES_EQUITY = 4; + private static final int PRODUCT_TYPE_SHARES = 4; + + /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ + private static final int PRODUCT_TYPE_SAVINGS = 2; + private static final int FA_SAVINGS_CONTROL = 2; + + private static final int SHARE_STATUS_ACTIVE = 300; + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** m_payment_type.value seeded by changelog 0483 for this settlement. */ + private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); + + // ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ────────────── + + /** + * Whether this share account can be settled from savings, and for how much. + * + * Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked, + * does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the + * counter instead of after a request has been raised and has to be rejected. + */ + @GET + @Path("/eligibility/{shareAccountId}") + public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + final Map acct = loadShareAccount(shareAccountId); + + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + + final Map out = new LinkedHashMap<>(); + out.put("shareAccountId", shareAccountId); + out.put("shareAccountNo", acct.get("account_no")); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalRequired", required); + + final Object savingsAccountIdRaw = acct.get("savings_account_id"); + if (savingsAccountIdRaw == null) { + out.put("eligible", false); + out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead."); + return Response.ok(gson.toJson(out)).build(); + } + final Long savingsAccountId = toLong(savingsAccountIdRaw); + final Map savings = loadSavingsAccount(savingsAccountId); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + final BigDecimal available = balance == null ? BigDecimal.ZERO : balance; + + out.put("savingsAccountId", savingsAccountId); + out.put("savingsAccountNo", savings.get("account_no")); + out.put("savingsBalance", available); + + final String blocker = settlementBlocker(acct, savings, required, available); + out.put("eligible", blocker == null); + out.put("reason", blocker); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Why this share account cannot be settled from savings right now, or null when it can. + * + * Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement + * is allowed. + */ + private String settlementBlocker(final Map shareAccount, final Map savings, + final BigDecimal required, final BigDecimal available) { + + if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) { + return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled."; + } + if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) { + return "Share account " + shareAccount.get("account_no") + " has already been settled."; + } + if (required.compareTo(BigDecimal.ZERO) <= 0) { + return "There is nothing outstanding on share account " + shareAccount.get("account_no") + "."; + } + // The savings account must belong to the same member — debiting anyone else's balance for + // these shares would be taking one member's money for another's holding. + if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) { + return "The linked savings account belongs to a different member."; + } + if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { + return "Savings account " + savings.get("account_no") + " is not Active."; + } + if (available.compareTo(required) < 0) { + return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required + + " is required. Take a deposit first, or settle by cash collection."; + } + return null; + } + + // ── POST /v1/sharesavingsdebit ── Clerk records the request ───────────── + + @POST + @Transactional + public Response submit(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = parseBody(body); + final Long shareAccountId = getLong(json, "shareAccountId"); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required."); + } + + final Map acct = loadShareAccount(shareAccountId); + final Object savingsAccountIdRaw = acct.get("savings_account_id"); + if (savingsAccountIdRaw == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings", + "Share account " + acct.get("account_no") + " has no linked savings account."); + } + final Long savingsAccountId = toLong(savingsAccountIdRaw); + final Map savings = loadSavingsAccount(savingsAccountId); + + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + + // Checked at submit as well as at approval: the clerk should be told now, not after the + // member has left and a manager finds the account short. + final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); + if (blocker != null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); + } + + final Long existingPending = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class, + shareAccountId); + if (existingPending != null && existingPending > 0) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending", + "A savings-debit request for this share account is already pending review."); + } + + final Long newId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_savings_debit_request + (share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount, + currency_code, status, clerk_id, clerk_name, note) + VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?) + RETURNING id + """, Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required, + acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note")); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", newId); + out.put("status", "PENDING"); + out.put("totalAmount", required); + return Response.ok(gson.toJson(out)).build(); + } + + // ── GET /v1/sharesavingsdebit ── Requests awaiting review ─────────────── + + @GET + public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status, + @QueryParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name, + r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance, + r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status, + r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at, + r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note + FROM m_share_savings_debit_request r + JOIN m_share_account sa ON sa.id = r.share_account_id + JOIN m_client c ON c.id = r.client_id + LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id + WHERE 1 = 1 + """); + final List params = new ArrayList<>(); + if (!"ALL".equalsIgnoreCase(status)) { + sql.append(" AND r.status = ? "); + params.add(status.toUpperCase()); + } + if (shareAccountId != null) { + sql.append(" AND r.share_account_id = ? "); + params.add(shareAccountId); + } + sql.append(" ORDER BY r.submitted_at DESC, r.id DESC"); + + return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build(); + } + + // ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ───────── + + /** + * Debits the member's savings and issues the shares. + * + * Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the + * approval is the point at which it actually matters. + */ + @POST + @Path("/{id}/approve") + @Transactional + public Response approve(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); + validateCheckerRole(currentUser); + + final Map req = loadRequest(id); + if (!"PENDING".equals(req.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", + "Only PENDING requests can be approved. This one is " + req.get("status") + "."); + } + if (toLong(req.get("clerk_id")).equals(currentUser.getId())) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval", + "Maker-checker: you cannot approve a request you submitted yourself. Ask another manager."); + } + + final Long shareAccountId = toLong(req.get("share_account_id")); + final Long savingsAccountId = toLong(req.get("savings_account_id")); + final Map acct = loadShareAccount(shareAccountId); + final Map savings = loadSavingsAccount(savingsAccountId); + + // Recomputed rather than taken from the request: shares or charges may have been edited + // between submit and approval, and the member must be debited for what is actually owed. + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final BigDecimal required = shareAmount.add(chargeAmount); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + + final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); + if (blocker != null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); + } + + final Long productId = toLong(acct.get("product_id")); + final Long officeId = toLong(acct.get("office_id")); + final String currencyCode = (String) acct.get("currency_code"); + final String accountNo = (String) acct.get("account_no"); + + final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String note = "Share application settled from savings | " + accountNo; + + // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL + // pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money + // became share capital, which the pair below records. + final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHSAV-" + savingsTxnId; + + insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application debited from savings " + savings.get("account_no") + " | " + accountNo); + insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share capital issued for " + accountNo); + + if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); + } + + // Marks the account settled, which is what stops it being settled again by either route. + this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); + + this.jdbcTemplate.update(""" + UPDATE m_share_savings_debit_request + SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), + share_amount = ?, charge_amount = ?, total_amount = ?, + savings_transaction_id = ?, gl_transaction_id = ? + WHERE id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("status", "APPROVED"); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("totalDebited", required); + out.put("savingsTransactionId", savingsTxnId); + return Response.ok(gson.toJson(out)).build(); + } + + // ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ─────────── + + @POST + @Path("/{id}/reject") + @Transactional + public Response reject(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map req = loadRequest(id); + if (!"PENDING".equals(req.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", + "Only PENDING requests can be rejected. This one is " + req.get("status") + "."); + } + + final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body); + this.jdbcTemplate.update(""" + UPDATE m_share_savings_debit_request + SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? + WHERE id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("status", "REJECTED"); + return Response.ok(gson.toJson(out)).build(); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, + final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId(); + } + + /** + * The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the + * withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out. + * The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it + * on the member's statement and in payment-type reporting. + * + * Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles + * rather than failing at approval with money already half-moved. + */ + private Long resolveSettlementPaymentTypeId() { + final List> rows = this.jdbcTemplate + .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE); + if (!rows.isEmpty()) { + return toLong(rows.get(0).get("id")); + } + final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1"); + if (fallback.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type", + "No payment types are configured. Add one before settling a share application from savings."); + } + return toLong(fallback.get(0).get("id")); + } + + /** Savings Control GL for this savings account's product. */ + private Long savingsControlGlOf(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT apm.gl_account_id FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 + """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); + return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); + } + + /** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */ + private BigDecimal shareValueOf(final Long shareAccountId) { + final BigDecimal total = this.jdbcTemplate + .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " + + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + /** Entry fee still outstanding, zero when no charge was attached. */ + private BigDecimal outstandingChargeOf(final Long shareAccountId) { + final BigDecimal total = this.jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge " + + " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0", + BigDecimal.class, shareAccountId); + return total == null ? BigDecimal.ZERO : total; + } + + private Map loadShareAccount(final Long shareAccountId) { + try { + return this.jdbcTemplate.queryForMap(""" + SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, + sa.collection_applied, sa.savings_account_id, c.office_id + FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ? + """, shareAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found", + "Share account " + shareAccountId + " does not exist."); + } + } + + private Map loadSavingsAccount(final Long savingsAccountId) { + try { + return this.jdbcTemplate.queryForMap( + "SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", + savingsAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found", + "Savings account " + savingsAccountId + " does not exist."); + } + } + + private Map loadRequest(final Long id) { + try { + return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found", + "Savings-debit request " + id + " does not exist."); + } + } + + private Long requireGl(final Long productId, final int financialAccountType, final String label) { + try { + final Long glId = this.jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + if (glId == null) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } catch (final IncorrectResultSizeDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + this.jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as + * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's + * own zone first, which is the zone {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } + + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required", + "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request."); + } + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required."); + } + try { + return JsonParser.parseString(body).getAsJsonObject(); + } catch (final RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON."); + } + } + + private Long getLong(final JsonObject json, final String field) { + return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; + } + + private String getString(final JsonObject json, final String field) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return null; + } + final String value = json.get(field).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private Long toLong(final Object o) { + return o instanceof Number n ? n.longValue() : null; + } + + private int toInt(final Object o) { + return o instanceof Number n ? n.intValue() : 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index bbea5abf1..53913d2da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource { activityLog, Timestamp.from(Instant.now()), requestType ); + // A collection pulls money into the GL, so it reads as a credit; a disbursement and a + // bank transfer both move money out of the source side, so they read as a debit. + final String voucherPrefix = "COLLECTION".equals(requestType) + ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT; final String voucherNo = voucherService.createVoucher( VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST, - newId, txnAmount, currentUser); + newId, txnAmount, currentUser, voucherPrefix); jdbcTemplate.update( "UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId); @@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource { return ps; }, btLogKh); final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); - final String btVoucherNo = generateBtVoucherNo(btLogId); + final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId); } @@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource { return ps; }, logKh); final long logId = Objects.requireNonNull(logKh.getKey()).longValue(); - final String voucherNo = generateBtVoucherNo(logId); + final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId); final JsonObject res = new JsonObject(); @@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource { * Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even * under concurrent inserts on the same date. */ - private String generateBtVoucherNo(final long logId) { - return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd")) - + String.format("%05d", logId); + /** + * Bank-transfer settlement voucher, drawn from the same central register as every other + * voucher so the two numbering schemes cannot drift apart. A settlement moves money out of + * the debited accounts, so it reads as a debit: DR/001/2108/0001. + */ + private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { + return voucherService.createVoucher( + VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG, + logId, amount, currentUser, VoucherService.PREFIX_DEBIT); } // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index 8a51e1603..fc2bad879 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; -import java.math.RoundingMode; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; @@ -58,36 +57,29 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Maker-checker workflow for a member to claim an unclaimed dividend (see - * {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability - * that was never credited into any savings account). A clerk (maker) records exactly how the - * member wants it split when they visit the branch: straight into an overdue loan, credited to - * their linked savings account, or handed over as cash -- any combination, any leftover amount - * stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes - * the real disbursement, or rejects it, leaving the unclaimed liability untouched. + * Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status + * {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk + * (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the + * draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a + * later visit, and is forfeited to profit if three years pass. * *

- * Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native - * repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct), - * which debits whatever GL account is actually configured as that product's Fund Source -- not - * necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own - * "Warehouse Receipt" account instead). Since the money never actually came from that account, - * that same DEBIT leg is rewritten in place to Dividend Payable (see - * {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual - * entry pair, so the transaction ends up with exactly the entries a real accountant would expect - * -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched -- - * instead of that fund-source account confusingly appearing twice. The cash leg has no account to - * post through, so approval - * posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the - * physical hand-over is deferred: the claim's {@code cash_status} becomes - * {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be - * redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before - * the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s - * submit/approve/disburse token flow for ordinary savings withdrawals. + * A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and + * loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member + * holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no + * counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on + * those historical rows — but new claims only ever write {@code dd_apply_amount}. + * + *

+ * The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD + * number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter + * debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the + * member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue + * request, and why no charge or GST attaches to it. */ @Path("/v1/clerk/dividend-claim-requests") @Component -@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.") +@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.") @RequiredArgsConstructor public class DividendClaimRequestApiResource { @@ -98,44 +90,27 @@ public class DividendClaimRequestApiResource { private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101; + /** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */ + private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + + /** + * Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the + * member — so the register shows who actually raised it rather than repeating the beneficiary's name in both + * columns, which would read as a counter sale to the member. + */ + private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)"; private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name", "r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id", - "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", - "r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", - "l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", - "r.submitted_at", "r.actioned_at" }; - - // ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ────────── - - /** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */ - @GET - @Path("/overdue-loans/{clientId}") - @Produces(MediaType.APPLICATION_JSON) - public String overdueLoans(@PathParam("clientId") final Long clientId) { - final List> loans = jdbcTemplate.queryForList(""" - SELECT ln.id AS loan_id, ln.account_no, - SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0) - - COALESCE(ri.principal_writtenoff_derived, 0), 0) - + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0) - - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount - FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id - WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE - GROUP BY ln.id, ln.account_no - ORDER BY overdue_amount DESC - """, clientId); - final JsonArray result = new JsonArray(); - for (final Map loan : loans) { - final JsonObject row = new JsonObject(); - row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue()); - row.addProperty("accountNo", (String) loan.get("account_no")); - row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount")); - result.add(row); - } - return gson.toJson(result); - } + "(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount", + "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id", + "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at", + // The Demand Draft this claim is collected as — the only settlement route for new claims. + // loan/savings/cash columns above remain for claims settled before those routes were retired. + "r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id", + "r.dd_number" }; // ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ── @@ -147,18 +122,10 @@ public class DividendClaimRequestApiResource { final AppUser currentUser = context.authenticatedUser(); final JsonElement root = fromJsonHelper.parse(body); final Long lockId = fromJsonHelper.extractLongNamed("lockId", root); - final Long loanId = fromJsonHelper.extractLongNamed("loanId", root); - final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root); - final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root)); - final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root)); - // Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), - // while a dividend share often works out to paise -- floor to the nearest rupee (never - // trusting the frontend to have already done so) so the denomination breakdown the - // cashier counts at disburse time can always match exactly; any leftover paise is simply - // not claimed here and stays part of the lock's balance for a future claim. - final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root)) - .setScale(0, RoundingMode.FLOOR); - final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); + final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root)); + final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root)); + final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root)); + final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root)); if (lockId == null) { throw apiError(400, "lockId is required"); } @@ -171,53 +138,28 @@ public class DividendClaimRequestApiResource { } final Long clientId = ((Number) lock.get("client_id")).longValue(); final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount"); - final Object lockSavingsAccountId = lock.get("savings_account_id"); - // The share account isn't required to have a linked savings account up front any more -- - // if the member wants a savings credit and none is linked, the clerk supplies which - // account to credit right here. - Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue() - : savingsAccountIdOverride; - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (resolvedSavingsAccountId == null) { - throw apiError(400, - "This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero"); - } - final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, - resolvedSavingsAccountId); - if (savingsClientId == null || !clientId.equals(savingsClientId)) { - throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client"); - } + if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(400, "ddApplyAmount is required and must be greater than zero"); + } + if (ddApplyAmount.compareTo(unclaimedAmount) > 0) { + throw apiError(400, + "Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount); + } + if (ddBeneficiaryName == null) { + throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee"); + } + if (ddPayableAtBranch == null || ddPayableAtCity == null) { + throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft"); } - final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount); - if (total.compareTo(BigDecimal.ZERO) <= 0) { - throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero"); - } - if (total.compareTo(unclaimedAmount) > 0) { - throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount); - } - - if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (loanId == null) { - throw apiError(400, "loanId is required when loanApplyAmount is greater than zero"); - } - final Map loan = jdbcTemplate - .queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst() - .orElseThrow(() -> apiError(404, "Loan not found: " + loanId)); - if (!clientId.equals(((Number) loan.get("client_id")).longValue())) { - throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client"); - } - if (((Number) loan.get("loan_status_id")).intValue() != 300) { - throw apiError(400, "Loan " + loanId + " is not Active"); - } - } - - if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - if (tokNo == null || tokNo.isBlank()) { - throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero"); - } - validateTokenUniqueToday(tokNo, null); + // A draft can only be raised if there is stock to raise it on. Checked here rather than at + // approval so the clerk finds out while the member is still at the counter, instead of the + // claim sitting pending and failing later. + final Long availableLeaflets = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class); + if (availableLeaflets == null || availableLeaflets == 0) { + throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."); } final Long existingPending = jdbcTemplate.queryForObject( @@ -226,40 +168,85 @@ public class DividendClaimRequestApiResource { throw apiError(409, "A claim request for this dividend is already pending review."); } + validateNotGuarantorOfOverdueLoan(clientId); + + // loan/savings/cash apply amounts are written as zero: those routes are retired, but the + // columns remain for claims settled through them historically. final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_share_dividend_claim_request - (lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount, - cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?) + (lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount, + dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city, + status, clerk_id, clerk_name, submitted_at) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?) RETURNING id - """, Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount, - cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), - cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null); + """, Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } /** - * Ensures the token number is unique for today's date across both this table and - * {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same - * Transaction Entry field, so the two pools must never collide. + * Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan. + * + * A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security + * behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed + * loan is in arrears hands over money the society may be about to demand back from the very same member, and once + * a draft is drawn there is nothing left to recover it from. + * + * Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has + * not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted + * on. The block lifts on its own the moment the borrower clears the arrears. + * + * Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell + * the member standing at the counter why — and so the member can go to the borrower about it. */ - private void validateTokenUniqueToday(final String tokNo, final Long excludeId) { - final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request " - + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'"); - final List params = new ArrayList<>(); - params.add(tokNo); - if (excludeId != null) { - sql.append(" AND id <> ?"); - params.add(excludeId); + private void validateNotGuarantorOfOverdueLoan(final Long clientId) { + final List> overdueGuarantees = jdbcTemplate.queryForList(""" + SELECT l.account_no, + COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name, + COALESCE(SUM(ri.principal_amount + ri.interest_amount + - COALESCE(ri.principal_completed_derived, 0) + - COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount + FROM m_guarantor g + JOIN m_loan l ON l.id = g.loan_id + LEFT JOIN m_client b ON b.id = l.client_id + JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id + WHERE g.entity_id = ? + AND g.type_enum = 1 + AND g.is_active = true + AND l.loan_status_id IN (300, 303, 304, 700) + AND ri.completed_derived = false + AND ri.duedate < CURRENT_DATE + GROUP BY l.account_no, b.display_name + HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount + - COALESCE(ri.principal_completed_derived, 0) + - COALESCE(ri.interest_completed_derived, 0)), 0) > 0 + ORDER BY overdue_amount DESC + """, clientId); + + if (overdueGuarantees.isEmpty()) { + return; } - final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()); - final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " - + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo); - final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount); - if (total > 0) { - throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number."); + + final StringBuilder detail = new StringBuilder(); + for (final Map guarantee : overdueGuarantees) { + if (detail.length() > 0) { + detail.append("; "); + } + detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name")) + .append(") overdue ").append(guarantee.get("overdue_amount")); } + + throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: " + + detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends."); + } + + private String trimToNull(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; } private BigDecimal parseAmount(final String value) { @@ -317,373 +304,207 @@ public class DividendClaimRequestApiResource { if (!"PENDING".equals(record.get("status"))) { throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status")); } + // 409 rather than 403: the user has every right to approve dividend claims — just not this + // one, because they raised it. A 403 reads as "you lack permission", which sends the checker + // to an administrator to fix access that was never the problem. final Long makerId = ((Number) record.get("clerk_id")).longValue(); if (makerId.equals(currentUser.getId())) { - throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager."); + throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a " + + "payout: one to raise it, another to check it. Ask a different manager to approve this claim."); } final Long lockId = ((Number) record.get("lock_id")).longValue(); final Map lock = jdbcTemplate - .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream() - .findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId)); + .queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst() + .orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId)); if (!"UNCLAIMED".equals(lock.get("status"))) { throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")"); } + // Re-checked at approval, not just at submission: a claim can sit pending for days, and a + // guaranteed loan can fall into arrears in that window. Approval is the point the draft is + // actually drawn, so it is the last moment the society can still hold the money back. + validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue()); + final String currencyCode = (String) lock.get("currency_code"); final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount"); - final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount"); - final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount"); - final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount"); - final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount); - if (total.compareTo(unclaimedAmount) > 0) { + final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount"); + if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(409, "This claim has no Demand Draft amount recorded."); + } + if (ddApplyAmount.compareTo(unclaimedAmount) > 0) { throw apiError(409, "The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit."); } - final Object claimSavingsAccountId = record.get("savings_account_id"); - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) { - throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion."); - } final LocalDate today = DateUtils.getBusinessLocalDate(); - final String note = "Dividend claim #" + id + " (lock #" + lockId + ")"; + final Map issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today, + currentUser); - if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - final Long loanId = ((Number) record.get("loan_id")).longValue(); - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", today.toString()); - payload.addProperty("transactionAmount", loanApplyAmount); - payload.addProperty("note", note); - final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId) - .withJson(payload.toString()).build(); - final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand); - redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null); - } - - if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", today.toString()); - payload.addProperty("transactionAmount", savingsApplyAmount); - payload.addProperty("note", note); - payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId()); - final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue(); - final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId) - .withJson(payload.toString()).build(); - final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand); - redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId()); - } - - if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) { - payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today); - } - - final BigDecimal remainder = unclaimedAmount.subtract(total); + final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount); if (remainder.compareTo(BigDecimal.ZERO) > 0) { - jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, - lockId); + jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId); } else { jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId); } - final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null; jdbcTemplate.update(""" UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?, - cash_status = ? + dd_issue_request_id = ?, dd_number = ? WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id); + """, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), + issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id); final JsonObject response = new JsonObject(); response.addProperty("resourceId", id); response.addProperty("status", "APPROVED"); - response.addProperty("totalDisbursed", total); + response.addProperty("totalDisbursed", ddApplyAmount); response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO)); + response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber")); + response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId")); return gson.toJson(response); } - // ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ── - /** - * Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction - * Entry types the clerk-supplied token number here to resolve the approved cash amount before - * disbursing it, instead of the manager handing over cash directly at approval time. + * Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable + * liability rather than by anything the member pays in. + * + * A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they + * are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable + * and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those + * columns are written as zero. + * + * The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a + * real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other. */ - @GET - @Path("/by-token/{token}") - @Produces(MediaType.APPLICATION_JSON) - public String getByToken(@PathParam("token") final String token) { - final List> rows = jdbcTemplate.queryForList(""" - SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount, - r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at - FROM m_share_dividend_claim_request r - JOIN m_client c ON c.id = r.client_id - WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE - ORDER BY r.submitted_at DESC LIMIT 1 - """, token); + private Map issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId, + final Map record, final BigDecimal amount, final String currencyCode, final LocalDate today, + final AppUser currentUser) { - if (rows.isEmpty()) { - throw apiError(404, "Token '" + token + "' not found for today. Please check the token number."); - } + // Oldest series first, and locked so two concurrent approvals cannot take the same leaflet. + final Map leaflet = jdbcTemplate.queryForList(""" + SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' + ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED + """).stream().findFirst() + .orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory.")); + final Long leafletId = ((Number) leaflet.get("id")).longValue(); + final String ddNumber = (String) leaflet.get("dd_number"); - final Map row = rows.get(0); - final String status = String.valueOf(row.get("status")); - final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status")); + final Long clientId = ((Number) record.get("client_id")).longValue(); + final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); - if ("PENDING".equals(status)) { - throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet."); - } - if ("REJECTED".equals(status)) { - throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed."); - } - if (!"APPROVED".equals(status)) { - throw apiError(422, "Token '" + token + "' has unexpected status: " + status); - } - if ("DISBURSED".equals(cashStatus)) { - throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier."); - } - if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) { - throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement."); - } - return gson.toJson(row); - } + // APPROVED outright: the manager approving this claim IS the check on it, and a second + // maker-checker round on the draft would leave a member's paid dividend in limbo. + // client_id still records WHOSE dividend this is — the entitlement belongs to them and the + // draft has to be traceable back to the member. walkin_purchaser_name names who actually + // raised it, which is the society, so the two roles stay distinguishable. + final Long issueRequestId = jdbcTemplate.queryForObject(""" + INSERT INTO m_sponsor_dd_issue_request + (leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city, + face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, + payment_method, status, maker_id, created_date, checker_id, checker_date) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?) + RETURNING id + """, Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), + record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today), + ((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(), + Timestamp.from(Instant.now())); - // ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ── + // The leaflet carries its own copy of the draft's details, and the DD register reads from + // there rather than from the issue request — so setting the status alone leaves the draft + // listed with every column blank. Mirrors what the ordinary issue flow writes. + // + // issued_to_client_id is deliberately left NULL and the purchaser named as the society + // instead. On an ordinary draft the client IS the purchaser — they walked in and bought it. + // Here the society is paying the member their own dividend, so recording the member as + // purchaser would read as though they had bought the draft, and the register would show the + // same name as both purchaser and beneficiary with nothing to distinguish a + // society-initiated draft from a counter sale. + jdbcTemplate.update(""" + UPDATE m_sponsor_dd_leaflet + SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL, + walkin_purchaser_name = ?, beneficiary_name = ?, + payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?, + advice_status = 'NOT_SENT' + WHERE id = ? + """, amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"), + record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId); - /** - * Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at - * approve time, so this step is purely a physical-cash-handover record with double-disbursement - * protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction. - */ - @POST - @Path("/{id}/disburse") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Transactional - public String disburse(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = context.authenticatedUser(); - final Map record = jdbcTemplate - .queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?", - id) - .stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id)); + postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser); - final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status")); - if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) { - throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus); - } - - String denominationData = null; - BigDecimal transactionAmount = null; - if (body != null && !body.isBlank()) { - final JsonElement root = fromJsonHelper.parse(body); - denominationData = fromJsonHelper.extractStringNamed("denominationData", root); - transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root)); - } - if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) { - throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement."); - } - - // cash_apply_amount is floored to whole rupees at submit time now (see submit() above), - // but a claim created before that fix may still carry paise -- floor here too so an older - // claim can still be disbursed in whole-rupee cash rather than being stuck forever. - final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount"); - final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR); - if (disbursableAmount.compareTo(transactionAmount) != 0) { - throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of " - + disbursableAmount + " (cash can only be handed over in whole rupees)."); - } - - final long denomTotal = denominationTotal(denominationData); - if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) { - throw apiError(400, - "Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount); - } - - final int updated = jdbcTemplate.update(""" - UPDATE m_share_dividend_claim_request SET - cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ? - WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT' - """, currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id); - - if (updated == 0) { - throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented."); - } - - if (denominationData != null && !denominationData.isBlank()) { - final Long clientId = ((Number) record.get("client_id")).longValue(); - final Long lockId = ((Number) record.get("lock_id")).longValue(); - saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser); - } - - return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED")); - } - - /** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */ - private long denominationTotal(final String denominationData) { - if (denominationData == null || denominationData.isBlank()) { - return 0; - } - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (final Exception e) { - throw apiError(400, "denominationData is not valid JSON"); - } - if (denomMap == null) { - return 0; - } - long total = 0; - for (final Map.Entry entry : denomMap.entrySet()) { - final String[] parts = entry.getKey().split(":", 2); - if (parts.length != 2) { - continue; - } - final int value; - final int count; - try { - value = Integer.parseInt(parts[1]); - count = entry.getValue().getAsInt(); - } catch (final Exception e) { - continue; - } - if (count > 0) { - total += (long) value * count; - } - } - return total; + final Map result = new java.util.LinkedHashMap<>(); + result.put("issueRequestId", issueRequestId); + result.put("ddNumber", ddNumber); + return result; } /** - * Records the cashier's note/coin breakdown for a dividend cash disbursement into - * {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings - * transaction backing this (the cash leg is a pure GL entry posted at approve time), so this - * claim request's own id is used as the stable txn identifier for grouping. + * Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but + * now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and + * paid, through the normal DD settlement. + * + * Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration + * and forfeiture entries take, so a missing GL mapping never traps a member's dividend. */ - private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId, - final String denominationData, final AppUser cashier) { - final JsonObject denomMap; - try { - denomMap = gson.fromJson(denominationData, JsonObject.class); - } catch (final Exception e) { - return; - } - if (denomMap == null || denomMap.size() == 0) { - return; - } + private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode, + final BigDecimal amount, final LocalDate today, final AppUser currentUser) { - for (final Map.Entry entry : denomMap.entrySet()) { - final int count; - try { - count = entry.getValue().getAsInt(); - } catch (final Exception e) { - continue; - } - if (count <= 0) { - continue; - } - - final String[] parts = entry.getKey().split(":", 2); - if (parts.length != 2) { - continue; - } - final String kind = parts[0]; - final int value; - try { - value = Integer.parseInt(parts[1]); - } catch (final Exception e) { - continue; - } - - final List ids = jdbcTemplate.queryForList( - "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1", - Long.class, value, kind); - if (ids.isEmpty()) { - continue; - } - final Long denomId = ids.get(0); - - jdbcTemplate.update(""" - INSERT INTO m_savings_txn_denomination - (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?) - """, claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count, - BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername()); - } - } - - /** - * Fineract's savings deposit command mandates paymentTypeId even for an internal posting with - * no real customer payment instrument -- resolves the system-seeded "Dividend Claim - * Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which - * would break on any tenant seeded differently. - */ - private Long resolveDividendClaimPaymentTypeId() { - final Long paymentTypeId = jdbcTemplate - .queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream() + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + final Long dividendPaidAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_DIVIDEND_PAID) + .stream().findFirst().orElse(null); + final Long ddPayableAccountId = jdbcTemplate + .queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream() .findFirst().orElse(null); - if (paymentTypeId == null) { - throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations."); - } - return paymentTypeId; - } - - /** - * The loan repayment / savings deposit command just posted its own native GL entries assuming - * the money came out of whichever account is that product's actual Fund Source (not always - * literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its - * own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with - * a second manual entry pair (which would leave that fund-source account appearing twice, once - * debited then immediately credited back -- a confusing "wash" no real accountant would post), - * this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction - * ends up with exactly the entries a real accountant would expect: Dividend Payable debited, - * Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still - * updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of - * the two ids is non-null per call. - */ - private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) { - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null) { - return; - } - if (loanTransactionId != null) { - jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?", - payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); - } - if (savingsTransactionId != null) { - jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?", - payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); - } - } - - /** - * A straight cash handover has no account to post through, so Cash at Main Vault genuinely is - * correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the - * member's hand. - */ - private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount, - final LocalDate entryDate) { - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - final Long cashAtVaultAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null || cashAtVaultAccountId == null) { + if (payableDividendsAccountId == null || ddPayableAccountId == null) { return; } - final AppUser currentUser = context.authenticatedUser(); - final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); - final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime(); - final String description = "Dividend claim #" + claimRequestId + " disbursed"; + final java.sql.Date entryDate = java.sql.Date.valueOf(today); + final String transactionId = "DIVDD" + claimId; + // Stamped with the financial year the dividend was DECLARED for, not the year it is being + // claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot + // say how much of each year's declaration has actually been claimed. + final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #" + + claimId + " (lock #" + lockId + ")"; + + if (dividendPaidAccountId == null) { + // Dividend Paid not mapped: post straight across, exactly as before this account existed. + // A society that has not configured it still gets a correct payout, just without the + // separate "how much has been claimed" measure. + postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description, + currentUser); + return; + } + + // Routed through Dividend Paid rather than posted straight across, so the society can read + // off what has been claimed and what is still unclaimed as two separate balances instead of + // only the net of Dividend Payable. + // + // Two pairs, in the order the money moves, each with its own description — reading Dividend + // Paid's ledger has to show which line settled the member's entitlement and which raised the + // draft, and one shared description makes the two indistinguishable. + postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate, + description + " (member's entitlement settled)", currentUser); + // The obligation does not end here: the society still owes the money until the draft is + // presented and paid, which is why this moves to Sponsor DD Payable rather than to cash. + // Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed. + postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, + description + " (draft raised)", currentUser); + } + + /** + * Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction + * the money travelled and the ledger reads in order. + */ + private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, + final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description, + final AppUser currentUser) { + final String insertSql = """ INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, @@ -691,13 +512,12 @@ public class DividendClaimRequestApiResource { submitted_on_date, transaction_date) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; - this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount, + description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); + jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount, + description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); } - // ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ─── @POST @Path("/{id}/reject") @@ -742,6 +562,7 @@ public class DividendClaimRequestApiResource { private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount, + dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city, status, clerk_id, clerk_name FROM m_share_dividend_claim_request WHERE id = ? """, id); @@ -751,6 +572,28 @@ public class DividendClaimRequestApiResource { return rows.get(0); } + /** + * The financial year of the declaration a lock came from — walking lock to per-member detail to declaration. + * + * Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being + * paid their dividend. + */ + private String financialYearOfLock(final Long lockId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.dividend_period_end_date + FROM m_share_dividend_lock l + JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + WHERE l.id = ? + """, lockId); + if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) { + return "unknown"; + } + final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date"))); + final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + private WebApplicationException apiError(final int httpStatus, final String message) { final JsonObject errorBody = new JsonObject(); errorBody.addProperty("message", message); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java index 89e7a78bc..43be84903 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java @@ -51,11 +51,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout - * progress, full 3-year lock history, and members whose unclaimed dividend (see - * {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can - * proactively apply it towards that EMI instead of waiting for the member to claim it or for it - * to be forfeited to profit after 3 years. + * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year + * lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan + * installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or + * for it to be forfeited to profit after 3 years. */ @Path("/v1/clerk/dividend-dashboard") @Component @@ -112,8 +111,7 @@ public class DividendDashboardApiResource { final JsonObject result = new JsonObject(); result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue()); - result.addProperty("pendingApprovalDeclarations", - ((Number) declarationStats.get("pending_approval_declarations")).longValue()); + result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue()); result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue()); result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue()); result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue()); @@ -132,9 +130,9 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/declarations ──────────────────────────── /** - * Every dividend ever declared (across every share product), so a manager tracking multiple - * dividends at once can see -- per declaration -- how many members it covers and how far its - * payout has actually progressed, not just whether it was approved. + * Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can + * see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just + * whether it was approved. */ @GET @Path("/declarations") @@ -179,25 +177,34 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/locks ─────────────────────────────────── /** - * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still - * counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan -- - * see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's - * profit). Optional {@code status} query param filters to one. + * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a + * member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3 + * years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one. */ @GET @Path("/locks") @Produces(MediaType.APPLICATION_JSON) public String locks(@QueryParam("status") final String status) { final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status); + // Which declaration each lock came from. Once a member has more than one unclaimed dividend — + // this year's plus a prior year they never collected — amount and locked date alone cannot + // tell them apart, so the clerk has no way to say which year is being paid out. The join + // walks lock -> per-member detail -> declaration -> product to name the period and product. final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, " + "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, " - + "l.applied_loan_id, ln.account_no AS applied_loan_account_no " // + + "l.applied_loan_id, ln.account_no AS applied_loan_account_no, " + + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " // + "FROM m_share_dividend_lock l " // + "JOIN m_client c ON c.id = l.client_id " // + "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " // + "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " // + + "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " // + + "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " // + + "LEFT JOIN m_share_product p ON p.id = d.product_id " // + (filterByStatus ? "WHERE l.status = ? " : "") // - + "ORDER BY l.locked_date DESC"; + // Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that + // needs attention, and it is the one a member should be paid out of first. + + "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC"; final List> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase()) : jdbcTemplate.queryForList(sql); @@ -217,6 +224,17 @@ public class DividendDashboardApiResource { final Object appliedLoanId = row.get("applied_loan_id"); entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue()); entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no")); + final Object declarationId = row.get("declaration_id"); + entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue()); + entry.addProperty("productName", (String) row.get("product_name")); + final Object periodStart = row.get("dividend_period_start_date"); + final Object periodEnd = row.get("dividend_period_end_date"); + entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString()); + entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString()); + // The financial year the dividend was earned in, taken from the period end so a + // declaration is labelled by the year it closes — "FY 2026-27", the way a member and an + // auditor both refer to it. + entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString())); result.add(entry); } return gson.toJson(result); @@ -260,17 +278,19 @@ public class DividendDashboardApiResource { } private JsonArray overdueLoansFor(final Long clientId) { - final List> loans = jdbcTemplate.queryForList(""" - SELECT ln.id AS loan_id, ln.account_no, - SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0) - - COALESCE(ri.principal_writtenoff_derived, 0), 0) - + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0) - - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount - FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id - WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE - GROUP BY ln.id, ln.account_no - ORDER BY overdue_amount DESC - """, clientId); + final List> loans = jdbcTemplate.queryForList( + """ + SELECT ln.id AS loan_id, ln.account_no, + SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0) + - COALESCE(ri.principal_writtenoff_derived, 0), 0) + + GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0) + - COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount + FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id + WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE + GROUP BY ln.id, ln.account_no + ORDER BY overdue_amount DESC + """, + clientId); final JsonArray result = new JsonArray(); for (final Map loan : loans) { final JsonObject row = new JsonObject(); @@ -285,14 +305,13 @@ public class DividendDashboardApiResource { // ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ──────────────── /** - * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue - * loan EMI (see {@code list()} above) and applies it without waiting for the member to visit - * and file a claim. Since the dividend was never credited into any savings account (it's a - * pure "Dividend Payable" GL liability until claimed -- see - * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to - * make: this posts a native loan repayment (so the loan's own schedule/state stays fully - * native and correct) and then redirects the debit leg it assumed the cash came from to the - * Dividend Payable liability instead -- identical mechanic to a claim's loan leg. + * Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see + * {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend + * was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see + * {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a + * native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the + * debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a + * claim's loan leg. */ @POST @Path("/{lockId}/apply-to-loan") @@ -341,15 +360,14 @@ public class DividendDashboardApiResource { payload.addProperty("transactionDate", today.toString()); payload.addProperty("transactionAmount", applyAmount); payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")"); - final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId) - .withJson(payload.toString()).build(); + final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString()) + .build(); final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand); redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId()); final BigDecimal remainder = unclaimedAmount.subtract(applyAmount); if (remainder.compareTo(BigDecimal.ZERO) > 0) { - jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, - lockId); + jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId); } else { jdbcTemplate.update(""" UPDATE m_share_dividend_lock @@ -366,16 +384,14 @@ public class DividendDashboardApiResource { } /** - * The loan repayment just posted above assumed the money came out of whichever account is - * this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a - * Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead) - * -- it didn't really. Rather than reverse that with a second manual entry pair (which would - * leave that fund-source account appearing twice, once debited then immediately credited back - * -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's - * account in place to Dividend Payable, so the transaction ends up with exactly the entries a - * real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited, - * nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend - * Payable financial activity. + * The loan repayment just posted above assumed the money came out of whichever account is this loan product's + * actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product + * debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second + * manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately + * credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account + * in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect: + * Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if + * the bank hasn't mapped the Dividend Payable financial activity. */ private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) { final Long payableDividendsAccountId = jdbcTemplate @@ -389,6 +405,16 @@ public class DividendDashboardApiResource { payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT); } + /** + * The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending + * 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it. + */ + private String financialYearOf(final String isoDate) { + final LocalDate date = LocalDate.parse(isoDate); + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + private WebApplicationException apiError(final int httpStatus, final String message) { final JsonObject errorBody = new JsonObject(); errorBody.addProperty("message", message); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java index ffcccd5c2..a64e785b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java @@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData { minimumActivePeriodFrequencyTypeOption, currenMarketPrice); } + /** + * Whether the member's money has actually been taken and the shares issued — either by applying a cash collection + * or by debiting their savings. + * + * Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and + * the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved, + * which is what the Transactions, Passbook and Charges views actually describe. + */ + private Boolean collectionApplied; + public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) { this.currentMarketPrice = currentMarketPrice; } + public void setCollectionApplied(final Boolean collectionApplied) { + this.collectionApplied = collectionApplied; + } + public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) { this.linkedLoanId = linkedLoanId; this.linkedLoanAccountNo = linkedLoanAccountNo; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java index 79953b34d..70f6dd8fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountCharge.java @@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom { this.amountOrPercentage = chargeAmount; if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) { this.percentage = null; - this.amount = BigDecimal.ZERO; + // A flat charge is owed in full the moment it is attached: its amount IS the + // configured fee, and all of it is outstanding until something pays it. Leaving + // both at zero (as this once did) made the entry fee invisible to every consumer + // that reads amount/amount_outstanding_derived — the share-collection settlement + // then asked the member for the share value alone and the fee went uncollected. + this.amount = chargeAmount; this.amountPercentageAppliedTo = null; this.amountPaid = null; - this.amountOutstanding = BigDecimal.ZERO; + this.amountOutstanding = calculateOutstanding(); this.amountWaived = null; this.amountWrittenOff = null; } else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java index 37e77ca5f..d37112a9c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/jobs/forfeitunclaimeddividends/ForfeitUnclaimedDividendsTasklet.java @@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.jdbc.core.JdbcTemplate; /** - * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year - * release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is - * forfeited — the hold on the member's savings account is released and the amount is swept back - * into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for - * the actual GL/balance mechanics. + * Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with + * no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is + * released and the amount is swept back into the bank's profit. See + * {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics. */ @Slf4j @RequiredArgsConstructor @@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet { @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { final List exceptions = new ArrayList<>(); + // 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure + // "Dividend Payable" GL liability and renamed the initial status accordingly (see + // makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on + // 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently + // selected zero rows and nothing was ever forfeited. final List lockIds = this.jdbcTemplate.queryForList( - "SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class); + "SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class); for (final Long lockId : lockIds) { try { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 0e257bcf5..1e1c7dcc1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -654,18 +654,25 @@ public class ShareAccountDataSerializer { } private void handlechargesOnActivation(final ShareAccount account) { - Set charges = account.getCharges(); - for (ShareAccountCharge charge : charges) { - if (charge.isActive() && charge.isShareAccountActivation()) { - charge.markAsFullyPaid(); - } - } - + // The entry fee is deliberately NOT marked paid here. Activating the account decides the + // member may hold shares; it does not collect anything, and nobody has yet said where the + // money comes from — a cash receipt at the counter, or a debit from the member's savings. + // + // Marking it paid at activation left amount_paid_derived at the full fee and + // amount_outstanding_derived at zero while nothing had actually been received, so the + // settlement step read the fee as already settled and asked the member for the share value + // alone ("shares 400.00 + charges 0"). The fee was then never collected at all. + // + // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource + // (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set + // amount_paid_derived and amount_outstanding_derived alongside the flag. + // + // The charge transaction is still dated to the activation date — that records when the fee + // became due, which is a separate thing from when it was paid. Set transactions = account.getChargeTransactions(); for (ShareAccountTransaction transaction : transactions) { if (transaction.isChargeTransaction()) { transaction.updateTransactionDate(account.getActivatedDate()); - transaction.updateAmountPaid(transaction.amount()); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index 038249f89..a77facb3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false); final BigDecimal currentMarketPrice = deriveMarketPrice(productData); data.setCurrentMarketPrice(currentMarketPrice); + // Read separately rather than added to the row mapper, which is shared with the list and + // template queries that have no use for it. + data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account " + + " WHERE id = ?", Boolean.class, id)); if (!includeTemplate) { Collection dividends = this.retrieveAssociatedDividends(id); data.setDividends(dividends); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index ffc9f7f8a..30d621458 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -40,8 +40,13 @@ import org.springframework.transaction.annotation.Transactional; public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService { private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400; + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + private final ShareAccountDividendRepository shareAccountDividendRepository; private final SavingsAccountDomainService savingsAccountDomainService; private final SavingsAccountAssembler savingsAccountAssembler; @@ -62,12 +67,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe } /** - * Real-world UCB accounting: an approved dividend is never credited into a member's savings - * account up front -- it stays purely a "Dividend Payable" GL liability (see - * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the - * member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed - * dividend past its 3-year deadline has no savings hold to release and no account balance to - * deduct -- the liability is simply written off directly against the bank's profit. + * Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it + * stays purely a "Dividend Payable" GL liability (see + * {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually + * claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no + * savings hold to release and no account balance to deduct -- the liability is simply written off directly against + * the bank's profit. */ @Override @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -84,22 +89,55 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe postForfeitureJournalEntry(lockId, currencyCode, amount); - this.jdbcTemplate.update( - "UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); } /** - * Debit writes off the Dividend Payable liability (it was never going to be paid); credit - * increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off. - * Reads currency straight off the lock row (set from the share account at creation time) -- - * this never depended on a linked savings account existing. Skipped if the bank hasn't mapped - * both GL accounts yet, exactly like the declaration-time Reserve entry. + * The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is + * broken — a missing label must not stop the forfeiture from posting. + */ + private String financialYearOfLock(final Long lockId) { + final java.util.List> rows = this.jdbcTemplate.queryForList(""" + SELECT d.dividend_period_end_date + FROM m_share_dividend_lock l + JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + WHERE l.id = ? + """, lockId); + if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) { + return "unknown"; + } + final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date"))); + final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + /** + * Writes off a dividend nobody came to claim. + * + *
+     *   Dr  Dividend Payable            — the obligation ends; it was never going to be paid
+     *       Cr  Dividend Forfeited Income
+     * 
+ * + * Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and + * loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every + * other income account, which is the same equity account the dividend was declared out of — so the money completes + * its round trip, by way of the P&L. + * + * Currency comes straight off the lock row (set from the share account when the lock was created), so this never + * depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the + * declaration-time entries. */ private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) { final Long payableDividendsAccountId = this.jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) .stream().findFirst().orElse(null); + // Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank + // Profit along with every other income account — so the money still ends up back in the + // equity account the dividend was declared out of, but by way of the P&L, where a forfeited + // dividend is visible as that year's income rather than appearing in equity with no trace. final Long forfeitedIncomeAccountId = this.jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME) @@ -112,7 +150,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe final LocalDate entryDate = DateUtils.getBusinessLocalDate(); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); final String transactionId = "DIVFORFEIT" + lockId; - final String description = "Dividend forfeited to profit — share dividend lock #" + lockId; + // Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in — + // a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each + // year's declaration went unclaimed and came back to profit. + final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId; final String insertSql = """ INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, @@ -120,10 +161,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe submitted_on_date, transaction_date) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; - this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + // Debit first, then credit, so the entry ids follow the direction the money travelled. + this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, + amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT, + amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 6377c494e..1c7f1307f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -1,522 +1,528 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.shareaccounts.service; - -import jakarta.persistence.PersistenceException; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; -import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; -import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; -import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType; -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.service.DateUtils; -import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent; -import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent; -import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.portfolio.account.service.AccountNumberGenerator; -import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; -import org.apache.fineract.portfolio.note.domain.Note; -import org.apache.fineract.portfolio.note.domain.NoteRepository; -import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; -import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction; -import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; -import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; -import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.orm.jpa.JpaSystemException; - -@RequiredArgsConstructor -public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService { - - private final ShareAccountDataSerializer accountDataSerializer; - - private final ShareAccountRepositoryWrapper shareAccountRepository; - - private final ShareProductRepositoryWrapper shareProductRepository; - - private final AccountNumberGenerator accountNumberGenerator; - - private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; - - private final JournalEntryWritePlatformService journalEntryWritePlatformService; - - private final NoteRepository noteRepository; - - private final JdbcTemplate jdbcTemplate; - - private final BusinessEventNotifierService businessEventNotifierService; - - @Override - public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) { - try { - ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand); - this.shareAccountRepository.saveAndFlush(account); - generateAccountNumber(account); - // GL entries are intentionally NOT posted at submission time. - // Cashier collects cash and enters GL directly in the transaction entry - // workstation: DR Cash → CR Share Sundry GL (share amount) and - // DR Cash → CR Charges GL (charge amount). The approval step posts the - // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. - - businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account)); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(account.getId()) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } catch (final PersistenceException dve) { - Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); - handleDataIntegrityIssues(jsonCommand, throwable, dve); - return CommandProcessingResult.empty(); - } - } - - private void generateAccountNumber(final ShareAccount account) { - if (account.isAccountNumberRequiresAutoGeneration()) { - final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES); - account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat)); - this.shareAccountRepository.save(account); - } - } - - private Map populateJournalEntries(final ShareAccount account, final Set transactions) { - final Map accountingBridgeData = new HashMap<>(); - Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2; - accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting); - accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE); - accountingBridgeData.put("shareAccountId", account.getId()); - accountingBridgeData.put("shareProductId", account.getShareProduct().getId()); - accountingBridgeData.put("officeId", account.getOfficeId()); - accountingBridgeData.put("currencyCode", account.getCurrency().getCode()); - final List> newTransactionsMap = new ArrayList<>(); - accountingBridgeData.put("newTransactions", newTransactionsMap); - - for (ShareAccountTransaction transaction : transactions) { - final Map transactionDto = new HashMap<>(); - transactionDto.put("officeId", account.getOfficeId()); - transactionDto.put("id", transaction.getId()); - transactionDto.put("date", transaction.getPurchasedDate()); - final Integer status = transaction.getTransactionStatus(); - final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null); - final Integer type = transaction.getTransactionType(); - final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null); - transactionDto.put("status", statusEnum); - transactionDto.put("type", typeEnum); - if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) { - BigDecimal amount = transaction.amount(); - if (transaction.chargeAmount() != null) { - amount = amount.add(transaction.chargeAmount()); - } - transactionDto.put("amount", amount); - } else { - transactionDto.put("amount", transaction.amount()); - } - - transactionDto.put("chargeAmount", transaction.chargeAmount()); - transactionDto.put("paymentTypeId", null); // FIXME::make it cash - // payment - if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) { - final List> chargesPaidData = new ArrayList<>(); - transactionDto.put("chargesPaid", chargesPaidData); - Set chargesPaidBySet = transaction.getChargesPaidBy(); - for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) { - Map chargesPaidDto = new HashMap<>(); - chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId()); - chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId()); - chargesPaidDto.put("amount", chargesPaidBy.getAmount()); - chargesPaidData.add(chargesPaidDto); - } - } - newTransactionsMap.add(transactionDto); - } - return accountingBridgeData; - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - } - // since we are reverting all journal entries we need to add journal - // entries for application request - if (changes.containsKey("reversalIds")) { - ArrayList reversalIds = (ArrayList) changes.get("reversalIds"); - this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); - changes.remove("reversalIds"); - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } catch (final PersistenceException dve) { - Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); - handleDataIntegrityIssues(jsonCommand, throwable, dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account); - ShareAccountTransaction transaction = null; - if (!changes.isEmpty()) { - this.shareAccountRepository.saveAndFlush(account); - transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname); - transaction = account.getShareAccountTransaction(transaction); - if (transaction != null) { - changes.clear(); - changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId()); - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - } - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - Set transactions = account.getShareAccountTransactions(); - Set journalTransactions = new HashSet<>(); - Long totalSubsribedShares = Long.valueOf(0); - - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && transaction.isPurchasTransaction()) { - journalTransactions.add(transaction); - totalSubsribedShares += transaction.getTotalShares(); - } - } - ShareProduct shareProduct = account.getShareProduct(); - shareProduct.addSubscribedShares(totalSubsribedShares); - this.shareProductRepository.save(shareProduct); - - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); - - businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - Set transactions = account.getShareAccountTransactions(); - Set journalTransactions = new HashSet<>(); - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && !transaction.isChargeTransaction()) { - journalTransactions.add(transaction); - } - } - - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - changes.put("note", noteText); - this.noteRepository.save(note); - } - } - - Set transactions = account.getShareAccountTransactions(); - ArrayList journalEntryTransactions = new ArrayList<>(); - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && !transaction.isChargeTransaction()) { - journalEntryTransactions.add(transaction.getId()); - } - } - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - } - - // Cashier collected cash and entered GL directly via the transaction entry - // workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR - // Charges GL for charges). Mark charges as paid to reflect cashier's collection - // without posting duplicate GL entries — the approval step already posted the - // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. - jdbcTemplate.update( - "UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?", - accountId); - - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) { - - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); - Long totalSubscribedShares = Long.valueOf(0); - if (transactionIds != null) { - Set transactions = new HashSet<>(); - for (Long id : transactionIds) { - ShareAccountTransaction transaction = account.retrievePurchasedShares(id); - transactions.add(transaction); - totalSubscribedShares += transaction.getTotalShares(); - } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - if (!totalSubscribedShares.equals(Long.valueOf(0))) { - ShareProduct shareProduct = account.getShareProduct(); - shareProduct.addSubscribedShares(totalSubscribedShares); - this.shareProductRepository.save(shareProduct); - } - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @SuppressWarnings("unchecked") - @Override - public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.save(account); - ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); - if (transactionIds != null) { - Set transactions = new HashSet<>(); - for (Long id : transactionIds) { - ShareAccountTransaction transaction = account.retrievePurchasedShares(id); - transactions.add(transaction); - } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - } - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account); - if (!changes.isEmpty()) { - this.shareAccountRepository.saveAndFlush(account); - ShareAccountTransaction transaction = (ShareAccountTransaction) changes - .get(ShareAccountApiConstants.requestedshares_paramname); - // after saving, entity will have different object. So need to - // retrieve the entity object - transaction = account.getShareAccountTransaction(transaction); - Long redeemShares = transaction.getTotalShares(); - ShareProduct shareProduct = account.getShareProduct(); - // remove the redeem shares from total subscribed shares - shareProduct.removeSubscribedShares(redeemShares); - this.shareProductRepository.saveAndFlush(shareProduct); - - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - changes.clear(); - changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); - - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - @Override - public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) { - try { - ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); - Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account); - // Account status/closedDate are applied regardless of whether there was anything left to - // redeem — save and note-capture must not be gated on a transaction actually existing. - this.shareAccountRepository.saveAndFlush(account); - final String noteText = jsonCommand.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - final Note note = Note.shareNote(account, noteText); - this.noteRepository.save(note); - } - // Only closures that actually redeemed remaining shares carry a transaction to post GL - // entries for — an account already at zero shares has none, and posting a zero-value - // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL. - final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname); - if (transactionChange instanceof ShareAccountTransaction) { - ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange); - Set transactions = new HashSet<>(); - transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - changes.clear(); - changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); - } - return new CommandProcessingResultBuilder() // - .withCommandId(jsonCommand.commandId()) // - .withEntityId(accountId) // - .with(changes) // - .build(); - } catch (final JpaSystemException | DataIntegrityViolationException dve) { - handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); - return CommandProcessingResult.empty(); - } - } - - private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) { - throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue", - "Unknown data integrity issue with resource."); - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.service; + +import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; +import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; +import org.apache.fineract.infrastructure.accountnumberformat.domain.EntityAccountType; +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.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountApproveBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.share.ShareAccountCreateBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.account.service.AccountNumberGenerator; +import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; +import org.apache.fineract.portfolio.note.domain.Note; +import org.apache.fineract.portfolio.note.domain.NoteRepository; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction; +import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; +import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; +import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; + +@RequiredArgsConstructor +public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareAccountWritePlatformService { + + private final ShareAccountDataSerializer accountDataSerializer; + + private final ShareAccountRepositoryWrapper shareAccountRepository; + + private final ShareProductRepositoryWrapper shareProductRepository; + + private final AccountNumberGenerator accountNumberGenerator; + + private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; + + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + + private final NoteRepository noteRepository; + + + private final BusinessEventNotifierService businessEventNotifierService; + + @Override + public CommandProcessingResult createShareAccount(JsonCommand jsonCommand) { + try { + ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand); + this.shareAccountRepository.saveAndFlush(account); + generateAccountNumber(account); + // GL entries are intentionally NOT posted at submission time. + // Cashier collects cash and enters GL directly in the transaction entry + // workstation: DR Cash → CR Share Sundry GL (share amount) and + // DR Cash → CR Charges GL (charge amount). The approval step posts the + // share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting. + + businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account)); + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(account.getId()) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException dve) { + Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); + handleDataIntegrityIssues(jsonCommand, throwable, dve); + return CommandProcessingResult.empty(); + } + } + + private void generateAccountNumber(final ShareAccount account) { + if (account.isAccountNumberRequiresAutoGeneration()) { + final AccountNumberFormat accountNumberFormat = this.accountNumberFormatRepository.findByAccountType(EntityAccountType.SHARES); + account.updateAccountNumber(this.accountNumberGenerator.generate(account, accountNumberFormat)); + this.shareAccountRepository.save(account); + } + } + + private Map populateJournalEntries(final ShareAccount account, final Set transactions) { + final Map accountingBridgeData = new HashMap<>(); + Boolean cashBasedAccounting = account.getShareProduct().getAccountingType() == 2; + accountingBridgeData.put("cashBasedAccountingEnabled", cashBasedAccounting); + accountingBridgeData.put("accrualBasedAccountingEnabled", Boolean.FALSE); + accountingBridgeData.put("shareAccountId", account.getId()); + accountingBridgeData.put("shareProductId", account.getShareProduct().getId()); + accountingBridgeData.put("officeId", account.getOfficeId()); + accountingBridgeData.put("currencyCode", account.getCurrency().getCode()); + final List> newTransactionsMap = new ArrayList<>(); + accountingBridgeData.put("newTransactions", newTransactionsMap); + + for (ShareAccountTransaction transaction : transactions) { + final Map transactionDto = new HashMap<>(); + transactionDto.put("officeId", account.getOfficeId()); + transactionDto.put("id", transaction.getId()); + transactionDto.put("date", transaction.getPurchasedDate()); + final Integer status = transaction.getTransactionStatus(); + final ShareAccountTransactionEnumData statusEnum = new ShareAccountTransactionEnumData(status.longValue(), null, null); + final Integer type = transaction.getTransactionType(); + final ShareAccountTransactionEnumData typeEnum = new ShareAccountTransactionEnumData(type.longValue(), null, null); + transactionDto.put("status", statusEnum); + transactionDto.put("type", typeEnum); + if (transaction.isPurchaseRejectedTransaction() || transaction.isRedeemTransaction()) { + BigDecimal amount = transaction.amount(); + if (transaction.chargeAmount() != null) { + amount = amount.add(transaction.chargeAmount()); + } + transactionDto.put("amount", amount); + } else { + transactionDto.put("amount", transaction.amount()); + } + + transactionDto.put("chargeAmount", transaction.chargeAmount()); + transactionDto.put("paymentTypeId", null); // FIXME::make it cash + // payment + if (transaction.getChargesPaidBy() != null && !transaction.getChargesPaidBy().isEmpty()) { + final List> chargesPaidData = new ArrayList<>(); + transactionDto.put("chargesPaid", chargesPaidData); + Set chargesPaidBySet = transaction.getChargesPaidBy(); + for (ShareAccountChargePaidBy chargesPaidBy : chargesPaidBySet) { + Map chargesPaidDto = new HashMap<>(); + chargesPaidDto.put("chargeId", chargesPaidBy.getChargeId()); + chargesPaidDto.put("sharesChargeId", chargesPaidBy.getShareChargeId()); + chargesPaidDto.put("amount", chargesPaidBy.getAmount()); + chargesPaidData.add(chargesPaidDto); + } + } + newTransactionsMap.add(transactionDto); + } + return accountingBridgeData; + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult updateShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndUpdate(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + } + // since we are reverting all journal entries we need to add journal + // entries for application request + if (changes.containsKey("reversalIds")) { + ArrayList reversalIds = (ArrayList) changes.get("reversalIds"); + this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate); + journalEntryWritePlatformService.createJournalEntriesForShares( + populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); + changes.remove("reversalIds"); + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException dve) { + Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); + handleDataIntegrityIssues(jsonCommand, throwable, dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult applyAddtionalShares(final Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApplyAddtionalShares(jsonCommand, account); + ShareAccountTransaction transaction = null; + if (!changes.isEmpty()) { + this.shareAccountRepository.saveAndFlush(account); + transaction = (ShareAccountTransaction) changes.get(ShareAccountApiConstants.additionalshares_paramname); + transaction = account.getShareAccountTransaction(transaction); + if (transaction != null) { + changes.clear(); + changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId()); + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + } + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult approveShareAccount(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApprove(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + Set transactions = account.getShareAccountTransactions(); + Set journalTransactions = new HashSet<>(); + Long totalSubsribedShares = Long.valueOf(0); + + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && transaction.isPurchasTransaction()) { + journalTransactions.add(transaction); + totalSubsribedShares += transaction.getTotalShares(); + } + } + ShareProduct shareProduct = account.getShareProduct(); + shareProduct.addSubscribedShares(totalSubsribedShares); + this.shareProductRepository.save(shareProduct); + + // No journal entries here. Approving an application decides the member may hold the + // shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share + // Capital at this point issues share capital against a Sundry balance that holds no + // receipt for it — in effect funding one member's shares out of money other members + // paid in. The entries belong to settlement, where the money actually arrives: either + // applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting + // their savings (ShareSavingsDebitRequestApiResource). + businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult rejectShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndReject(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + Set transactions = account.getShareAccountTransactions(); + Set journalTransactions = new HashSet<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && !transaction.isChargeTransaction()) { + journalTransactions.add(transaction); + } + } + + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult undoApproveShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndUndoApprove(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + } + + Set transactions = account.getShareAccountTransactions(); + ArrayList journalEntryTransactions = new ArrayList<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.isActive() && !transaction.isChargeTransaction()) { + journalEntryTransactions.add(transaction.getId()); + } + } + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate); + journalEntryWritePlatformService.createJournalEntriesForShares( + populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + } + + // Charges are deliberately NOT marked paid here. Attaching an entry fee when the + // account is created records what the member owes; activating the account decides they + // may hold shares. Neither collects anything, and nobody has yet said where the money + // is coming from — a cash receipt at the counter, or a debit from the member's savings. + // + // Marking it paid at activation left the fee showing as settled while it was still + // outstanding, so the settlement step then asked the member for the share value alone + // and the fee was never collected at all. It is marked paid where the money actually + // arrives: ShareAdvanceCollectionApiResource (cash receipt) or + // ShareSavingsDebitRequestApiResource (savings debit), both of which set + // amount_paid_derived and amount_outstanding_derived alongside the flag. + + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult approveAdditionalShares(Long accountId, JsonCommand jsonCommand) { + + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndApproveAddtionalShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); + Long totalSubscribedShares = Long.valueOf(0); + if (transactionIds != null) { + Set transactions = new HashSet<>(); + for (Long id : transactionIds) { + ShareAccountTransaction transaction = account.retrievePurchasedShares(id); + transactions.add(transaction); + totalSubscribedShares += transaction.getTotalShares(); + } + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + if (!totalSubscribedShares.equals(Long.valueOf(0))) { + ShareProduct shareProduct = account.getShareProduct(); + shareProduct.addSubscribedShares(totalSubscribedShares); + this.shareProductRepository.save(shareProduct); + } + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @SuppressWarnings("unchecked") + @Override + public CommandProcessingResult rejectAdditionalShares(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndRejectAddtionalShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.save(account); + ArrayList transactionIds = (ArrayList) changes.get(ShareAccountApiConstants.requestedshares_paramname); + if (transactionIds != null) { + Set transactions = new HashSet<>(); + for (Long id : transactionIds) { + ShareAccountTransaction transaction = account.retrievePurchasedShares(id); + transactions.add(transaction); + } + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult redeemShares(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndRedeemShares(jsonCommand, account); + if (!changes.isEmpty()) { + this.shareAccountRepository.saveAndFlush(account); + ShareAccountTransaction transaction = (ShareAccountTransaction) changes + .get(ShareAccountApiConstants.requestedshares_paramname); + // after saving, entity will have different object. So need to + // retrieve the entity object + transaction = account.getShareAccountTransaction(transaction); + Long redeemShares = transaction.getTotalShares(); + ShareProduct shareProduct = account.getShareProduct(); + // remove the redeem shares from total subscribed shares + shareProduct.removeSubscribedShares(redeemShares); + this.shareProductRepository.saveAndFlush(shareProduct); + + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + changes.clear(); + changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); + + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + @Override + public CommandProcessingResult closeShareAccount(Long accountId, JsonCommand jsonCommand) { + try { + ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId); + Map changes = this.accountDataSerializer.validateAndClose(jsonCommand, account); + // Account status/closedDate are applied regardless of whether there was anything left to + // redeem — save and note-capture must not be gated on a transaction actually existing. + this.shareAccountRepository.saveAndFlush(account); + final String noteText = jsonCommand.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.shareNote(account, noteText); + this.noteRepository.save(note); + } + // Only closures that actually redeemed remaining shares carry a transaction to post GL + // entries for — an account already at zero shares has none, and posting a zero-value + // transaction would just add a meaningless ₹0.00/₹0.00 entry pair to the GL. + final Object transactionChange = changes.get(ShareAccountApiConstants.requestedshares_paramname); + if (transactionChange instanceof ShareAccountTransaction) { + ShareAccountTransaction transaction = account.getShareAccountTransaction((ShareAccountTransaction) transactionChange); + Set transactions = new HashSet<>(); + transactions.add(transaction); + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + changes.clear(); + changes.put(ShareAccountApiConstants.requestedshares_paramname, transaction.getId()); + } + return new CommandProcessingResultBuilder() // + .withCommandId(jsonCommand.commandId()) // + .withEntityId(accountId) // + .with(changes) // + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(jsonCommand, dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } + } + + private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dve) { + throw ErrorHandler.getMappable(dve, "error.msg.shareaccount.unknown.data.integrity.issue", + "Unknown data integrity issue with resource."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index eed5904cc..3d5aa3f2f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -125,11 +125,10 @@ public class ShareAccountsConfiguration { ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository, AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository, - org.springframework.jdbc.core.JdbcTemplate jdbcTemplate, BusinessEventNotifierService businessEventNotifierService) { return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository, accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository, - jdbcTemplate, businessEventNotifierService); + businessEventNotifierService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java new file mode 100644 index 000000000..6f84b86ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -0,0 +1,579 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +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.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.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.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Declares one dividend across every share product at once. + * + * A society's board resolves a single rate for a financial year — it does not resolve one for each share product — but + * Fineract only declares dividends one product at a time. Doing that by hand means repeating the same decision per + * product and hoping the rate and period match, when a mismatch would quietly pay two classes of member at different + * rates out of one pool. + * + * Each product still gets its own declaration row, because share capital lives in a separate GL per product + * (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one + * rate, one period, applied to every product that has members holding shares in it. + * + * The declarations are created through the existing per-product command, so the whole downstream chain — statutory + * appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it + * does for a single-product declaration. + */ +@Path("/v1/shareproducts/dividends") +@Component +@Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.") +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class AllProductsDividendApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; + private static final int DIVIDEND_STATUS_INITIATED = 100; + + /** The account a dividend is declared against, once year-end closing has landed the profit in it. */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; + + /** Every member receives the declared rate on their own capital — what an AGM resolution promises. */ + private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL"; + /** The pool is a share of distributable profit, split across members afterwards. */ + private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── GET /v1/shareproducts/dividends ── Every declaration, across products ── + + @GET + public Response list() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, d.product_id, p.name AS product_name, d.amount, + d.dividend_period_start_date, d.dividend_period_end_date, d.status, d.created_date, + (SELECT COUNT(*) FROM m_share_account_dividend_details x + WHERE x.dividend_pay_out_id = d.id AND x.amount > 0) AS member_count + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + ORDER BY d.dividend_period_end_date DESC, d.product_id + """); + + final List> entries = new ArrayList<>(); + for (final Map row : rows) { + final Map entry = new LinkedHashMap<>(); + entry.put("dividendId", row.get("id")); + entry.put("productId", row.get("product_id")); + entry.put("productName", row.get("product_name")); + entry.put("amount", row.get("amount")); + entry.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + entry.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date"))); + entry.put("financialYear", financialYearOf(String.valueOf(row.get("dividend_period_end_date")))); + entry.put("approved", ((Number) row.get("status")).intValue() != DIVIDEND_STATUS_INITIATED); + entry.put("memberCount", row.get("member_count")); + entry.put("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date"))); + entries.add(entry); + } + return Response.ok(gson.toJson(entries)).build(); + } + + // ── GET /v1/shareproducts/dividends/preview ── What a rate costs, per product ── + + /** + * What declaring this rate would cost, product by product, before anything is created. + * + * A rate is a promise about each member's own paid-up capital, so the amount it costs depends on how much capital + * each product's members held and for how long — which differs per product. Showing that split before the officer + * commits is what makes a single cross-product decision safe to take. + * + * A product with no eligible members is reported with a zero and a reason rather than omitted, so it is clear it + * was considered and skipped rather than overlooked. + */ + @GET + @Path("/preview") + public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("ratePercentage") final BigDecimal ratePercentage, + @QueryParam("dividendBasis") final String dividendBasis) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final LocalDate from = parseDate(fromDate); + final LocalDate to = parseDate(toDate); + if (to.isBefore(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + final String basis = normaliseBasis(dividendBasis); + + final List capitals = capitalPerProduct(from, to); + final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); + + final ProfitWaterfall waterfall = profitWaterfall(from, to); + + // Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in + // proportion to the capital each carries — a share of profit is a society-wide figure, so it + // cannot be computed per product the way a rate on capital can. + final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null + ? percentageOf(waterfall.distributableProfit, ratePercentage) + : null; + + final List> products = new ArrayList<>(); + BigDecimal grandTotal = BigDecimal.ZERO; + + for (final ProductCapital capital : capitals) { + final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital, + profitPool); + + final Map entry = new LinkedHashMap<>(); + entry.put("productId", capital.productId); + entry.put("productName", capital.productName); + entry.put("weightedCapital", capital.weightedCapital); + entry.put("amount", amount); + entry.put("eligible", capital.weightedCapital.signum() > 0); + entry.put("reason", capital.weightedCapital.signum() > 0 ? null + : "No member has held shares in this product long enough to qualify for this period."); + entry.put("alreadyDeclared", hasDeclarationFor(capital.productId, from, to)); + products.add(entry); + grandTotal = grandTotal.add(amount); + } + + final Map out = new LinkedHashMap<>(); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("financialYear", financialYearOf(toDate)); + out.put("dividendBasis", basis); + out.put("ratePercentage", ratePercentage); + out.put("totalWeightedCapital", totalWeightedCapital); + out.put("profitGlCode", PROFIT_GL_CODE); + // The whole waterfall, so the screen can show where the declarable figure came from rather + // than presenting it as a number the officer has to take on trust. + out.putAll(waterfall.asMap()); + // What is left in the profit account after this declaration — the amount that will be swept + // to Bank Profit on approval, and which no later year's dividend can draw on. + out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO)); + out.put("exceedsDistributableProfit", grandTotal.compareTo(waterfall.distributableProfit) > 0); + out.put("totalAmount", grandTotal); + out.put("products", products); + return Response.ok(gson.toJson(out)).build(); + } + + // ── POST /v1/shareproducts/dividends ── Declare across every product ── + + /** + * Creates one declaration per eligible product, all for the same period and rate. + * + * Products with no eligible members, and products that already carry a declaration overlapping this period, are + * skipped and reported rather than failing the whole request — one product being ineligible is not a reason to + * leave the rest undeclared. + */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public Response declare(final String body) { + this.context.authenticatedUser(); + + final JsonObject json = parseBody(body); + final String fromDate = getString(json, "dividendPeriodStartDate"); + final String toDate = getString(json, "dividendPeriodEndDate"); + if (fromDate == null || toDate == null) { + throw apiError(400, "dividendPeriodStartDate and dividendPeriodEndDate (yyyy-MM-dd) are both required"); + } + final LocalDate from = parseDate(fromDate); + final LocalDate to = parseDate(toDate); + final String basis = normaliseBasis(getString(json, "dividendBasis")); + + final String rateText = getString(json, "ratePercentage"); + if (rateText == null) { + throw apiError(400, "ratePercentage is required"); + } + final BigDecimal rate = new BigDecimal(rateText); + if (rate.signum() <= 0) { + throw apiError(400, "ratePercentage must be greater than zero"); + } + + final List capitals = capitalPerProduct(from, to); + final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) + ? percentageOf(profitWaterfall(from, to).distributableProfit, rate) + : null; + + final List> created = new ArrayList<>(); + final List> skipped = new ArrayList<>(); + + for (final ProductCapital capital : capitals) { + final Long productId = capital.productId; + final String productName = capital.productName; + + if (hasDeclarationFor(productId, from, to)) { + skipped.add(skipRow(productId, productName, "A dividend is already declared for this product over this period.")); + continue; + } + + if (capital.weightedCapital.signum() <= 0) { + skipped.add(skipRow(productId, productName, + "No member has held shares in this product long enough to qualify for this period.")); + continue; + } + + final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool); + if (amount.signum() <= 0) { + skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product.")); + continue; + } + + // Routed through the existing per-product command so the whole downstream chain — + // appropriations, rounding, residue sweep, ceiling check — runs exactly as it does for a + // single-product declaration, rather than being reimplemented here. + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("dividendPeriodStartDate", fromDate); + payload.addProperty("dividendPeriodEndDate", toDate); + payload.addProperty("dividendAmount", amount); + + try { + final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId) + .withJson(payload.toString()).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); + + final Map row = new LinkedHashMap<>(); + row.put("productId", productId); + row.put("productName", productName); + row.put("amount", amount); + row.put("dividendId", result.getSubResourceId()); + created.add(row); + } catch (final RuntimeException e) { + // Most often the distributable-profit ceiling: an earlier product in this same run has + // already consumed the pool. Reported per product so the officer can see which ones + // went through and why the rest did not, instead of losing all of them to one failure. + skipped.add(skipRow(productId, productName, + e.getMessage() == null ? "Could not declare for this product." : e.getMessage())); + } + } + + if (created.isEmpty()) { + throw apiError(400, "No dividend could be declared. " + (skipped.isEmpty() ? "There are no share products." + : skipped.get(0).get("reason").toString())); + } + + final Map out = new LinkedHashMap<>(); + out.put("financialYear", financialYearOf(toDate)); + out.put("declarationsCreated", created.size()); + out.put("created", created); + out.put("skipped", skipped); + return Response.ok(gson.toJson(out)).build(); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /** One product's weighted paid-up capital for the period. */ + private static final class ProductCapital { + + private final Long productId; + private final String productName; + private final BigDecimal weightedCapital; + + private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) { + this.productId = productId; + this.productName = productName; + this.weightedCapital = weightedCapital; + } + } + + /** Weighted paid-up capital for every share product, in one pass. */ + private List capitalPerProduct(final LocalDate from, final LocalDate to) { + final List capitals = new ArrayList<>(); + for (final Map product : jdbcTemplate + .queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) { + final Long productId = ((Number) product.get("id")).longValue(); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod); + capitals.add(new ProductCapital(productId, (String) product.get("name"), + capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP))); + } + return capitals; + } + + /** + * What one product's declaration comes to, under whichever basis the officer chose. + * + * RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital, + * so each product's amount follows directly from the capital its members hold. + * + * PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure + * at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the + * same across products — any other split would pay two members with identical holdings different amounts purely + * because they hold different share classes. + */ + private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital, + final BigDecimal totalWeightedCapital, final BigDecimal profitPool) { + if (ratePercentage == null || weightedCapital.signum() <= 0) { + return BigDecimal.ZERO; + } + if (BASIS_PERCENT_OF_PROFIT.equals(basis)) { + if (profitPool == null || totalWeightedCapital.signum() <= 0) { + return BigDecimal.ZERO; + } + return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP); + } + return percentageOf(weightedCapital, ratePercentage); + } + + private BigDecimal percentageOf(final BigDecimal base, final BigDecimal percentage) { + return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + private String normaliseBasis(final String basis) { + return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL; + } + + /** + * The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of + * it, and what is left for a dividend. + * + * Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the + * society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a + * large one that has already been heavily appropriated — and the reserve and education percentages are + * configurable, so the amounts they take cannot be inferred from the result either. + */ + private static final class ProfitWaterfall { + + private final BigDecimal netProfit; + private final BigDecimal statutoryReservePercentage; + private final BigDecimal statutoryReserveAmount; + private final BigDecimal educationFundPercentage; + private final BigDecimal educationFundAmount; + private final BigDecimal distributableProfit; + + private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage, + final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage, + final BigDecimal educationFundAmount, final BigDecimal distributableProfit) { + this.netProfit = netProfit; + this.statutoryReservePercentage = statutoryReservePercentage; + this.statutoryReserveAmount = statutoryReserveAmount; + this.educationFundPercentage = educationFundPercentage; + this.educationFundAmount = educationFundAmount; + this.distributableProfit = distributableProfit; + } + + private Map asMap() { + final Map out = new LinkedHashMap<>(); + out.put("netProfit", netProfit); + out.put("statutoryReservePercentage", statutoryReservePercentage); + out.put("statutoryReserveAmount", statutoryReserveAmount); + out.put("educationFundPercentage", educationFundPercentage); + out.put("educationFundAmount", educationFundAmount); + out.put("distributableProfit", distributableProfit); + return out; + } + } + + /** + * The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education + * fund, which are appropriated before any dividend. + * + * Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never + * disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than + * guessed at from a P&L that is still moving. + */ + private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) { + final BigDecimal netProfit = netProfitForPeriod(from, to); + + final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); + final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); + + // Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and + // nothing to distribute, so the whole waterfall collapses to zero rather than producing a + // negative "reserve" that would read as money owed back to the society. + if (netProfit == null || netProfit.signum() <= 0) { + final BigDecimal zero = BigDecimal.ZERO.setScale(2); + return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero, + zero); + } + + final BigDecimal reserve = percentageOf(netProfit, reservePercentage); + final BigDecimal educationFund = percentageOf(netProfit, educationPercentage); + return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund, + netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO)); + } + + /** + * The period's net profit, read exactly the way the declaration validator reads it. + * + * This deliberately mirrors {@code ShareProductWritePlatformServiceJpaRepositoryImpl.profitForPeriod} rather than + * reading the profit GL directly. A closed year's profit lives in the profit GL, but a year is only "closed" once + * it is registered in {@code m_income_profit_transfer} — and after closing, income-less-expense for the period is + * zero, because closing is what zeroed those GLs. Reading the GL without that check would show a healthy profit on + * the preview and then have the declaration rejected for having none, which is exactly the contradiction an + * officer cannot act on. + */ + private BigDecimal netProfitForPeriod(final LocalDate from, final LocalDate to) { + final int fyStartYear = from.getMonthValue() >= 4 ? from.getYear() : from.getYear() - 1; + final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100); + final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31); + + if (!from.isAfter(fyStart) && !to.isBefore(fyEnd)) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount != null && closedCount > 0) { + final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + return closedProfit == null ? BigDecimal.ZERO : closedProfit; + } + } + + // The year is still open, so the profit is whatever the P&L says right now. + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to)); + return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense")); + } + + /** A configured appropriation rate, or zero when it has not been set. */ + private BigDecimal configuredPercentage(final String configurationName) { + final List values = jdbcTemplate.queryForList( + "SELECT value FROM c_configuration WHERE name = ? AND enabled = true", BigDecimal.class, configurationName); + return values.isEmpty() || values.get(0) == null ? BigDecimal.ZERO : values.get(0); + } + + /** + * Paid-up capital x days held, across a product's eligible accounts. + * + * Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a + * holding already in force at the period start is treated as starting there, and a purchase that has not cleared + * the product's minimum active period is skipped. + */ + private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, + final int minimumActivePeriod) { + final BigDecimal total = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(t.amount * (? - GREATEST(t.transaction_date, ?))), 0) + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? AND sa.status_enum = 300 + AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND t.transaction_date <= ? + AND (? - GREATEST(t.transaction_date, ?)) >= ? + """, BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to), + java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod); + return total == null ? BigDecimal.ZERO : total; + } + + private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) { + final Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_share_product_dividend_pay_out + WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, Integer.class, productId, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from)); + return count != null && count > 0; + } + + private Map skipRow(final Long productId, final String productName, final String reason) { + final Map row = new LinkedHashMap<>(); + row.put("productId", productId); + row.put("productName", productName); + row.put("reason", reason); + return row; + } + + /** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */ + private String financialYearOf(final String isoDate) { + final LocalDate date = LocalDate.parse(isoDate); + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + private LocalDate parseDate(final String isoDate) { + try { + return LocalDate.parse(isoDate); + } catch (final RuntimeException e) { + throw apiError(400, "Dates must be in yyyy-MM-dd format. Received: " + isoDate); + } + } + + private JsonObject parseBody(final String body) { + if (body == null || body.isBlank()) { + throw apiError(400, "Request body is required."); + } + try { + return com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + } catch (final RuntimeException e) { + throw apiError(400, "Request body is not valid JSON."); + } + } + + private String getString(final JsonObject json, final String field) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return null; + } + final String value = json.get(field).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject errorBody = new JsonObject(); + errorBody.addProperty("message", message); + errorBody.addProperty("defaultUserMessage", message); + errorBody.addProperty("httpStatus", httpStatus); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java index 9e04f493c..c4a63aa8c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendApiResource.java @@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.security.service.SqlValidator; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountDividendData; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountDividendReadPlatformService; import org.apache.fineract.portfolio.shareproducts.data.ShareProductDividendPayOutData; +import org.apache.fineract.portfolio.shareproducts.service.ShareDividendRoundingResidueReadService; import org.apache.fineract.portfolio.shareproducts.service.ShareProductDividendReadPlatformService; import org.springframework.stereotype.Component; @@ -61,6 +62,7 @@ public class ShareDividendApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final ShareAccountDividendReadPlatformService shareAccountDividendReadPlatformService; private final ShareProductDividendReadPlatformService shareProductDividendReadPlatformService; + private final ShareDividendRoundingResidueReadService shareDividendRoundingResidueReadService; private final SqlValidator sqlValidator; @GET @@ -100,6 +102,37 @@ public class ShareDividendApiResource { return this.toApiAccountDetailJsonSerializer.serialize(dividendDetails); } + /** + * What rounding left behind on one declaration. + * + * Members' entitlements are rounded down to whole rupees so each can be paid out as a Demand Draft, which leaves a + * few paise undistributed on every declaration. This reports that gap for the declaration being viewed — declared + * versus allocated versus residue — so the difference between the board's figure and the sum of the member rows on + * screen is explained rather than left as an apparent discrepancy. + */ + @GET + @Path("{dividendId}/rounding-residue") + @Produces({ MediaType.APPLICATION_JSON }) + public String retrieveRoundingResidue(@PathParam("productId") final Long productId, + @PathParam("dividendId") final Long dividendId) { + this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveOne(dividendId)); + } + + /** + * Rounding residue across every declaration of this product, newest period first. + * + * Kept per declaration and never deleted, so an auditor can be shown years afterwards exactly where each + * declaration's undistributed paise went and which journal entry moved them. + */ + @GET + @Path("rounding-residue") + @Produces({ MediaType.APPLICATION_JSON }) + public String retrieveRoundingResidueHistory(@PathParam("productId") final Long productId) { + this.platformSecurityContext.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.toApiJsonSerializer.serialize(this.shareDividendRoundingResidueReadService.retrieveAllForProduct(productId)); + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java new file mode 100644 index 000000000..aa99cbaf3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java @@ -0,0 +1,262 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * What a given dividend RATE actually costs for one share product over one period. + * + * "9% dividend" in an AGM resolution is a promise about each member's own paid-up capital: a member holding 10,000 of + * share capital for the full year receives 900. That is not the same thing as distributing 9% of the society's profit — + * under a profit-share the amount an individual member receives depends on how many OTHER members hold shares, so the + * same declared "9%" pays a different rate every year. This endpoint sizes the pool the promised way, so the amount + * declared is whatever it costs to actually honour the rate. + * + * The weighting matches {@code ShareProductDividendAssembler} exactly — same eligible-account rule, same + * minimum-active-period skip, same share-days accumulation — because that assembler is what splits the pool across + * members once the declaration is created. If the two disagreed, the rate quoted here would not be the rate members + * were paid. + */ +@Path("/v1/shareproduct/{productId}/dividend-capital-preview") +@Component +@Tag(name = "Share Dividend Capital Preview", description = "Weighted paid-up share capital for a period, and the pool required to pay a given rate per annum on it.") +@RequiredArgsConstructor +public class ShareDividendCapitalPreviewApiResource { + + /** Denominator for an annual rate; matches the assembler's exact-day arithmetic. */ + private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365); + + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("productId") final Long productId, @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, @QueryParam("ratePercentage") final BigDecimal ratePercentage) { + if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { + throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required"); + } + final Date from; + final Date to; + try { + from = Date.valueOf(fromDate); + to = Date.valueOf(toDate); + } catch (final IllegalArgumentException e) { + throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format"); + } + if (to.before(from)) { + throw apiError(400, "toDate must not be before fromDate"); + } + + final Map product = productOrThrow(productId); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + + final List> weighted = weightedCapitalPerAccount(productId, from, to, minimumActivePeriod, + allowInactiveClients); + + BigDecimal totalCapitalDays = BigDecimal.ZERO; + long eligibleAccounts = 0; + for (final Map account : weighted) { + final BigDecimal capitalDays = (BigDecimal) account.get("capitalDays"); + totalCapitalDays = totalCapitalDays.add(capitalDays); + if (capitalDays.signum() > 0) { + eligibleAccounts++; + } + } + + // Capital-days / 365 is the average capital that was actually at risk across the period — + // the base an annual rate applies to. A member who held for half the year contributes half + // their capital to it, which is exactly the pro-rata entitlement they are owed. + final BigDecimal weightedCapital = totalCapitalDays.divide(DAYS_IN_YEAR, 6, RoundingMode.HALF_UP); + + final Map response = new LinkedHashMap<>(); + response.put("productId", productId); + response.put("productName", product.get("name")); + response.put("fromDate", fromDate); + response.put("toDate", toDate); + response.put("minimumActivePeriodDays", minimumActivePeriod); + response.put("eligibleAccounts", eligibleAccounts); + response.put("totalCapitalDays", totalCapitalDays); + response.put("weightedCapital", weightedCapital); + if (ratePercentage != null) { + response.put("ratePercentage", ratePercentage); + response.put("requiredAmount", + weightedCapital.multiply(ratePercentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP)); + } else { + response.put("ratePercentage", null); + response.put("requiredAmount", null); + } + return gson.toJson(response); + } + + private Map productOrThrow(final Long productId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT name, minimum_active_period_frequency, allow_dividends_inactive_clients + FROM m_share_product WHERE id = ? + """, productId); + if (rows.isEmpty()) { + throw apiError(404, "Share product " + productId + " does not exist"); + } + return rows.get(0); + } + + /** + * Paid-up capital x days held, per eligible account. + * + * Mirrors {@code ShareProductDividendAssembler.calculateNumberOfShareDays}: only APPROVED transactions count, + * charge payments are excluded, a purchase already in force at the period start is treated as starting at the + * period start, and a purchase that has not yet cleared the minimum active period is skipped entirely. Redemptions + * reduce the running holding and the holding is floored at zero, so a redemption of shares whose purchase was + * skipped cannot drive an account negative — the same defensive floor the assembler carries. + * + * Unlike the assembler this weights by AMOUNT rather than share count, since a rate is quoted on paid-up capital + * and unit price can differ between purchases. + */ + private List> weightedCapitalPerAccount(final Long productId, final Date from, final Date to, + final int minimumActivePeriod, final boolean allowInactiveClients) { + // 300 = ACTIVE, 600 = CLOSED (ShareAccountStatusType); 300 = APPROVED transaction status, + // 500 = PURCHASED, 700 = CHARGE_PAYMENT (PurchasedSharesStatusType). + final String accountStatusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final String sql = """ + SELECT sa.id AS account_id, t.transaction_date, t.total_shares, t.unit_price, t.amount, t.type_enum + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? + """ + accountStatusClause + """ + AND t.status_enum = 300 + AND t.type_enum <> 700 + AND t.transaction_date <= ? + ORDER BY sa.id, t.transaction_date, t.id + """; + + final List> rows = allowInactiveClients ? jdbcTemplate.queryForList(sql, productId, from, to) + : jdbcTemplate.queryForList(sql, productId, to); + + final Map capitalDaysPerAccount = new LinkedHashMap<>(); + Long currentAccountId = null; + BigDecimal runningCapital = BigDecimal.ZERO; + java.time.LocalDate lastAppliedDate = null; + BigDecimal accumulated = BigDecimal.ZERO; + + final java.time.LocalDate periodStart = from.toLocalDate(); + final java.time.LocalDate periodEnd = to.toLocalDate(); + + for (final Map row : rows) { + final Long accountId = ((Number) row.get("account_id")).longValue(); + if (!accountId.equals(currentAccountId)) { + if (currentAccountId != null) { + capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital)); + } + currentAccountId = accountId; + runningCapital = BigDecimal.ZERO; + lastAppliedDate = null; + accumulated = BigDecimal.ZERO; + } + + java.time.LocalDate transactionDate = ((Date) row.get("transaction_date")).toLocalDate(); + if (transactionDate.isBefore(periodStart)) { + transactionDate = periodStart; + } + + final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500; + if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) { + continue; + } + + if (lastAppliedDate != null) { + final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, transactionDate); + accumulated = accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days))); + } + lastAppliedDate = transactionDate; + + final BigDecimal transactionCapital = capitalOf(row); + runningCapital = isPurchase ? runningCapital.add(transactionCapital) : runningCapital.subtract(transactionCapital); + if (runningCapital.signum() < 0) { + runningCapital = BigDecimal.ZERO; + } + } + if (currentAccountId != null) { + capitalDaysPerAccount.put(currentAccountId, closeAccount(accumulated, lastAppliedDate, periodEnd, runningCapital)); + } + + return capitalDaysPerAccount.entrySet().stream().map(entry -> { + final Map account = new LinkedHashMap<>(); + account.put("accountId", entry.getKey()); + account.put("capitalDays", entry.getValue()); + return account; + }).toList(); + } + + /** Carries the final holding forward to the period end, the way the assembler's tail does. */ + private BigDecimal closeAccount(final BigDecimal accumulated, final java.time.LocalDate lastAppliedDate, + final java.time.LocalDate periodEnd, final BigDecimal runningCapital) { + if (lastAppliedDate == null) { + return accumulated; + } + final long days = java.time.temporal.ChronoUnit.DAYS.between(lastAppliedDate, periodEnd); + return accumulated.add(runningCapital.multiply(BigDecimal.valueOf(days))); + } + + /** + * Paid-up capital the transaction moved. `amount` is authoritative where present; shares x unit price is the + * fallback for rows that predate amount being populated. + */ + private BigDecimal capitalOf(final Map row) { + final Object amount = row.get("amount"); + if (amount != null) { + return (BigDecimal) amount; + } + final Object shares = row.get("total_shares"); + final Object unitPrice = row.get("unit_price"); + if (shares == null || unitPrice == null) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(((Number) shares).longValue()).multiply((BigDecimal) unitPrice); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java index 90d9ac778..4b49ae857 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/domain/ShareProductDividendPayOutDetails.java @@ -82,4 +82,22 @@ public class ShareProductDividendPayOutDetails extends AbstractAuditableCustom { return ShareProductDividendStatusType.fromInt(this.status); } + /** + * Entitlements dropped for falling under the minimum payable amount, before they were zeroed. + * + * Not persisted — it exists only to carry the figure from the assembler, which is the only place that knows what a + * dropped member would have received, to the residue record written straight afterwards. Reading it back from + * m_share_account_dividend_details is impossible: those rows hold the zeroed amount, not the original. + */ + @jakarta.persistence.Transient + private BigDecimal belowMinimumAmount = BigDecimal.ZERO; + + public BigDecimal getBelowMinimumAmount() { + return this.belowMinimumAmount == null ? BigDecimal.ZERO : this.belowMinimumAmount; + } + + public void addBelowMinimumAmount(final BigDecimal amount) { + this.belowMinimumAmount = getBelowMinimumAmount().add(amount); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java new file mode 100644 index 000000000..47e1dae60 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareDividendRoundingResidueReadService.java @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.service; + +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.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Reports what a declaration did NOT pay out to members, and why. + * + * Two things hold an amount back. Every entitlement is rounded down to a whole rupee, because a dividend is paid as a + * Demand Draft and a branch cannot draw one for 421.32. And an entitlement below the minimum payable amount is dropped + * entirely, because drawing a draft for a few rupees costs the society more than the draft is worth. + * + * Both go to Bank Profit at declaration time — rounding down and dropping below the floor mean no member was ever + * promised that money, so unlike an unclaimed dividend there is nobody who could claim it and no three-year wait. The + * record is what persists: it is what answers an auditor asking why a declaration of 1,538.91 allocated 1,538 to + * members, per declaration, with the journal entry that moved the difference. + */ +@Service +@RequiredArgsConstructor +public class ShareDividendRoundingResidueReadService { + + private final JdbcTemplate jdbcTemplate; + + private static final String COLUMNS = """ + r.id, r.dividend_pay_out_id, r.product_id, p.name AS product_name, + r.declared_amount, r.allocated_amount, r.residue_amount, r.member_count, + r.below_minimum_count, r.below_minimum_amount, + r.currency_code, r.period_start_date, r.period_end_date, + r.swept_to_profit_date, r.gl_transaction_id + """; + + /** The residue for one declaration, or null if none was recorded (declarations made before this was tracked). */ + public Map retrieveOne(final Long dividendPayOutId) { + final List> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """ + FROM m_share_dividend_rounding_residue r + JOIN m_share_product p ON p.id = r.product_id + WHERE r.dividend_pay_out_id = ? + """, dividendPayOutId); + return rows.isEmpty() ? null : toResponse(rows.get(0)); + } + + /** Every declaration's residue for one product, newest period first, with a running total. */ + public Map retrieveAllForProduct(final Long productId) { + final List> rows = jdbcTemplate.queryForList("SELECT " + COLUMNS + """ + FROM m_share_dividend_rounding_residue r + JOIN m_share_product p ON p.id = r.product_id + WHERE r.product_id = ? + ORDER BY r.period_end_date DESC, r.id DESC + """, productId); + + final List> entries = new ArrayList<>(); + BigDecimal totalResidue = BigDecimal.ZERO; + for (final Map row : rows) { + entries.add(toResponse(row)); + totalResidue = totalResidue.add((BigDecimal) row.get("residue_amount")); + } + + final Map response = new LinkedHashMap<>(); + response.put("productId", productId); + response.put("declarationCount", entries.size()); + response.put("totalResidueSweptToProfit", totalResidue); + response.put("entries", entries); + return response; + } + + private Map toResponse(final Map row) { + final Map entry = new LinkedHashMap<>(); + entry.put("id", row.get("id")); + entry.put("declarationId", row.get("dividend_pay_out_id")); + entry.put("productId", row.get("product_id")); + entry.put("productName", row.get("product_name")); + entry.put("declaredAmount", row.get("declared_amount")); + entry.put("allocatedAmount", row.get("allocated_amount")); + entry.put("residueAmount", row.get("residue_amount")); + entry.put("memberCount", row.get("member_count")); + // How much of the residue came from entitlements too small to draw a draft for, as opposed + // to paise shaved off larger ones — the two are worth telling apart when reviewing whether + // the minimum is set sensibly. + entry.put("belowMinimumCount", row.get("below_minimum_count")); + entry.put("belowMinimumAmount", row.get("below_minimum_amount")); + entry.put("currencyCode", row.get("currency_code")); + entry.put("periodStartDate", String.valueOf(row.get("period_start_date"))); + entry.put("periodEndDate", String.valueOf(row.get("period_end_date"))); + final Object sweptDate = row.get("swept_to_profit_date"); + entry.put("sweptToProfitDate", sweptDate == null ? null : String.valueOf(sweptDate)); + entry.put("glTransactionId", row.get("gl_transaction_id")); + return entry; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java index 3755e227f..b742bbe0a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java @@ -19,9 +19,11 @@ package org.apache.fineract.portfolio.shareproducts.service; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -36,12 +38,20 @@ import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatf import org.apache.fineract.portfolio.shareproducts.data.ShareProductData; import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails; import org.apache.fineract.portfolio.shareproducts.exception.ShareAccountsNotFoundException; +import org.springframework.jdbc.core.JdbcTemplate; @RequiredArgsConstructor public class ShareProductDividendAssembler { + /** Smallest entitlement worth paying out; seeded by changelog 0480. */ + private static final String CONFIG_MINIMUM_PAYABLE_AMOUNT = "dividend-minimum-payable-amount"; + + /** Entitlements are rounded down to a multiple of this; seeded by changelog 0480. */ + private static final String CONFIG_ROUNDING_MULTIPLE = "dividend-rounding-multiple"; + private final ShareProductReadPlatformService shareProductReadPlatformService; private final ShareAccountReadPlatformService shareAccountReadPlatformService; + private final JdbcTemplate jdbcTemplate; public ShareProductDividendPayOutDetails calculateDividends(final Long productId, final BigDecimal amount, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { @@ -67,12 +77,28 @@ public class ShareProductDividendAssembler { if (numberOfShareDays > 0) { double amountPerShareDay = amount.doubleValue() / numberOfShareDays; + final BigDecimal minimumPayableAmount = configuredAmount(CONFIG_MINIMUM_PAYABLE_AMOUNT); + final BigDecimal roundingMultiple = configuredAmount(CONFIG_ROUNDING_MULTIPLE); productDividendPayOutDetails = new ShareProductDividendPayOutDetails(productId, Money.of(currency, amount).getAmount(), dividendPeriodStartDate, dividendPeriodEndDate); for (ShareAccountData accountData : shareAccountDatas) { long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId()); double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay; - final Money accountAmount = Money.of(currency, BigDecimal.valueOf(amountForAccount)); + // Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87 + // becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts + // cash in notes and coins, so round figures are what it can actually hand over. + // Rounding down rather than to nearest also guarantees the society never commits more + // than it declared: allocations always sum to at or below the declared amount. + BigDecimal roundedAmount = roundDownToMultiple(BigDecimal.valueOf(amountForAccount), roundingMultiple); + // An entitlement below the minimum is dropped to zero rather than paid: a draft costs + // the society a leaflet, stationery and counter time, so drawing one for a few rupees + // hands over less than it costs. What is dropped is swept to profit with the rounding + // residue and reported per declaration, so it is never silently lost. + if (roundedAmount.compareTo(minimumPayableAmount) < 0) { + productDividendPayOutDetails.addBelowMinimumAmount(roundedAmount); + roundedAmount = BigDecimal.ZERO; + } + final Money accountAmount = Money.of(currency, roundedAmount); ShareAccountDividendDetails dividendDetails = new ShareAccountDividendDetails(accountData.getId(), accountAmount.getAmount(), productDividendPayOutDetails); productDividendPayOutDetails.getAccountDividendDetails().add(dividendDetails); @@ -82,6 +108,33 @@ public class ShareProductDividendAssembler { return productDividendPayOutDetails; } + /** + * Rounds an entitlement down to the nearest multiple: at a multiple of 5, 546.789 becomes 545 and 121.87 becomes + * 120. + * + * A multiple of zero or one (or a disabled configuration row) means no multiple applies, and the amount is simply + * floored to a whole rupee — paise still cannot be paid out. + */ + private BigDecimal roundDownToMultiple(final BigDecimal amount, final BigDecimal multiple) { + if (multiple.compareTo(BigDecimal.ONE) <= 0) { + return amount.setScale(0, RoundingMode.FLOOR); + } + return amount.divide(multiple, 0, RoundingMode.FLOOR).multiply(multiple); + } + + /** + * An amount from configuration. A missing or disabled row means the rule does not apply to this society, which is + * zero — not a failure. + */ + private BigDecimal configuredAmount(final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + private long calculateNumberOfShareDays(final LocalDate postingDate, final LocalDate lastDividendPostDate, final int minimumActivePeriod, final Collection shareAccountDatas, final Map numberOfSharesdaysPerAccount) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index 9f831565c..62624f7d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.shareproducts.service; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import jakarta.persistence.PersistenceException; import java.math.BigDecimal; import java.time.LocalDate; @@ -43,7 +44,14 @@ import org.apache.fineract.infrastructure.event.business.domain.share.ShareProdu import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.domain.ProductPendingEdit; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository; import org.apache.fineract.portfolio.savings.domain.ProductPendingEditRepository; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField; +import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository; import org.apache.fineract.portfolio.shareproducts.constants.ShareProductApiConstants; import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct; import org.apache.fineract.portfolio.shareproducts.domain.ShareProductDividendPayOutDetails; @@ -54,14 +62,6 @@ import org.apache.fineract.portfolio.shareproducts.exception.ShareProductEditPen import org.apache.fineract.portfolio.shareproducts.exception.ShareProductNoPendingEditException; import org.apache.fineract.portfolio.shareproducts.exception.ShareProductSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.shareproducts.serialization.ShareProductDataSerializer; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedField; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditCharge; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMapping; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository; -import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMapper; -import com.google.gson.JsonObject; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.JdbcTemplate; @@ -90,8 +90,40 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP // FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole // accounting module into this constructor just for two int constants. - private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; + + /** + * The year's closed-out profit a dividend is declared out of; kept in step with + * IncomeProfitTransferService.PROFIT_GL_CODE. + */ + private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + + /** + * Where profit settles once a declaration has been approved — the undistributed remainder and the rounding + * residue. Nothing here ever enters a later dividend calculation. + */ + private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01"; + + // Statutory appropriation funds, seeded by changelog 0478. Resolved by GL code rather than a + // financial activity because they are society-specific funds with no native Fineract activity. + private static final String STATUTORY_RESERVE_GL_CODE = "STATRES01"; + private static final String EDUCATION_FUND_GL_CODE = "EDUFUND01"; + + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; + private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; + + private static final String JOURNAL_ENTRY_INSERT_SQL = """ + INSERT INTO acc_gl_journal_entry + (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, + description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc, + submitted_on_date, transaction_date) + VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) + """; + + private static final String CONFIG_STATUTORY_RESERVE_PERCENTAGE = "dividend-statutory-reserve-percentage"; + private static final String CONFIG_EDUCATION_FUND_PERCENTAGE = "dividend-education-fund-percentage"; @Override public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) { @@ -160,6 +192,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element); validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate); + validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount); + final String financialYear = financialYearOf(dividendPeriodEndDate); final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId, dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate); @@ -169,7 +203,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails); final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId); - postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount); + postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate, + dividendPeriodEndDate, financialYear); + postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount, + financialYear); + recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount, + dividendPayOutDetails.getBelowMinimumAmount(), dividendPeriodStartDate, dividendPeriodEndDate); businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId)); @@ -186,10 +225,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP /** * A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see - * {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is - * a real reservation of that period's profit. Two declarations covering the same days would - * pay members twice for the same period, so any overlap against an existing declaration for - * this product is blocked outright. + * {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is a real reservation of + * that period's profit. Two declarations covering the same days would pay members twice for the same period, so any + * overlap against an existing declaration for this product is blocked outright. */ private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { @@ -206,15 +244,443 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own - * Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just - * silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry - * pair rather than through the transaction-bound AccountingProcessorHelper (which expects a - * loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if - * the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity - * Mappings yet, so dividend declaration itself is never blocked by missing GL setup. + * Moves the profit this declaration did not distribute out of the distributable pool, once the declaration is + * approved. + * + *
+     *   Dr  Bank Profit (Non-Distributable)     what was left after the dividend
+     *       Cr  Bank Profit
+     * 
+ * + * Approval is the point at which the year's distribution is settled: the members' entitlements are fixed and no + * further dividend can be declared out of that profit. Leaving the remainder in the Non-Distributable account + * would carry it into the next year's balance, and the next declaration would size itself on profit that had + * already been through a distribution decision — offering the same money twice. + * + * The remainder is what the whole distributable pool for the period leaves after this declaration and any others + * already made against it, which is the same figure the declaration screen showed as available. Dated to the + * period's end rather than the day of approval, so it files under the year it belongs to: a 2025-26 dividend is + * often approved months into 2026-27. + * + * Skipped silently when there is nothing left, or when the GL setup is incomplete — approval must not be blocked + * by an account that has not been mapped. */ - private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) { + private void closeOffUndistributedProfit(final ShareProductDividendPayOutDetails declaration) { + + final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate(); + if (periodEndDate == null) { + return; + } + final Map period = jdbcTemplate.queryForMap( + "SELECT dividend_period_start_date, product_id FROM m_share_product_dividend_pay_out WHERE id = ?", declaration.getId()); + final LocalDate periodStartDate = ((java.sql.Date) period.get("dividend_period_start_date")).toLocalDate(); + + final java.sql.Date from = java.sql.Date.valueOf(periodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(periodEndDate); + + final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to); + if (netProfit.signum() <= 0) { + return; + } + final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE)) + .subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE)); + + // Every declaration against an overlapping period, this one included — the pool is + // society-wide, so a second product's dividend has already consumed part of it. + final BigDecimal declaredTotal = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out + WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, BigDecimal.class, to, from); + + final BigDecimal remainder = distributable.subtract(declaredTotal); + if (remainder.signum() <= 0) { + return; + } + + final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE); + final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + if (distributableAccountId == null || settledAccountId == null) { + return; + } + + final String financialYear = financialYearOf(periodEndDate); + // Dated to the day of approval, not the period end. Closing off the remainder is an act of + // the approval, so it must appear in the ledger on the day the officer performed it — + // back-dating it to 31 March files it months before it happened, where nobody reviewing the + // approval would find it. The financial year it belongs to is carried in the description. + postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")), + "DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), + "FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #" + + declaration.getId()); + } + + private String currencyCodeOfProduct(final Object productId) { + final List> rows = jdbcTemplate.queryForList("SELECT currency_code FROM m_share_product WHERE id = ?", + productId); + return rows.isEmpty() ? "INR" : (String) rows.get(0).get("currency_code"); + } + + /** + * Records what rounding left behind, and moves it to the society's own profit. + * + * Each member's entitlement is rounded down to a whole rupee (see {@code ShareProductDividendAssembler}) because a + * dividend is paid out as a Demand Draft and a branch cannot draw one for 421.32. The paise that fall off are not + * anybody's money: rounding DOWN means no member was ever promised them, so unlike an unclaimed dividend there is + * nobody who could turn up to claim it and no reason to hold it for three years. It goes to Bank Profit now. + * + * The row, however, is permanent. It is what answers an auditor years later asking why a declaration of 1,538.91 + * only allocated 1,538 to members — per declaration, with the journal entry that moved the difference. + * + * Skipped silently if the profit GL is not configured, leaving {@code swept_to_profit_date} null so the residue can + * be identified and swept once it is — the same posture the Reserve entry above takes, so incomplete GL setup never + * blocks a declaration. + */ + private void recordAndSweepRoundingResidue(final Long dividendPayOutId, final Long productId, final String currencyCode, + final BigDecimal declaredAmount, final BigDecimal belowMinimumAmount, final LocalDate periodStartDate, + final LocalDate periodEndDate) { + + final Map allocation = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(amount), 0) AS allocated, + COUNT(*) FILTER (WHERE amount > 0) AS member_count, + COUNT(*) FILTER (WHERE amount = 0) AS below_minimum_count + FROM m_share_account_dividend_details WHERE dividend_pay_out_id = ? + """, dividendPayOutId); + final BigDecimal allocatedAmount = (BigDecimal) allocation.get("allocated"); + final int memberCount = ((Number) allocation.get("member_count")).intValue(); + // Members allocated exactly zero were dropped for falling under the minimum payable amount + // (an ineligible member never reaches allocation at all — the assembler skips them earlier), + // so this counts precisely those whose entitlement was too small to draw a draft for. + final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue(); + final BigDecimal residue = declaredAmount.subtract(allocatedAmount); + + // Swept to the settled account, not back to the pool it came from: the residue belongs to the + // year that was just declared, and returning it to the distributable balance would put it + // straight back into what the next declaration measures. + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + + // A residue of zero still gets its row: "nothing was left over" is itself the answer to the + // audit question, and its absence would be indistinguishable from the sweep never running. + // + // The row is written now, at declaration, because it records what the allocation arithmetic + // produced. The journal entry that moves the money waits for approval — see + // sweepRoundingResidueOnApproval — because it debits Dividend Payable, and that account + // carries nothing until approval creates the liability. swept_to_profit_date stays null + // until then, which is what marks a residue as recorded-but-not-yet-swept. + jdbcTemplate.update(""" + INSERT INTO m_share_dividend_rounding_residue + (dividend_pay_out_id, product_id, declared_amount, allocated_amount, residue_amount, member_count, + below_minimum_count, below_minimum_amount, currency_code, period_start_date, period_end_date, + swept_to_profit_date, gl_transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + """, dividendPayOutId, productId, declaredAmount, allocatedAmount, residue, memberCount, belowMinimumCount, + belowMinimumAmount, currencyCode, java.sql.Date.valueOf(periodStartDate), java.sql.Date.valueOf(periodEndDate)); + } + + /** + * Moves what rounding left behind to the society's own profit, once the dividend is approved. + * + * Split from the recording of the residue because this entry debits Dividend Payable, and that liability does not + * exist until approval creates it. Sweeping at declaration time would drive Dividend Payable negative — the ledger + * would show the society owing members less than nothing while the dividend was still awaiting approval. + * + * Dated to the day of approval, alongside the release to Dividend Payable it takes back from. Both are acts of the + * approval and belong on the ledger the day it happened; the financial year they relate to is carried in the + * description rather than in the entry date. + */ + private void sweepRoundingResidueOnApproval(final Long dividendPayOutId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT residue_amount, currency_code, period_end_date + FROM m_share_dividend_rounding_residue + WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL + """, dividendPayOutId); + if (rows.isEmpty()) { + return; + } + + final Map row = rows.get(0); + final BigDecimal residue = (BigDecimal) row.get("residue_amount"); + if (residue == null || residue.signum() <= 0) { + return; + } + + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + // Left unswept rather than forced through: swept_to_profit_date stays null so the residue is + // still identifiable and can be swept once the GL setup is completed. + if (bankProfitAccountId == null || payableDividendsAccountId == null) { + return; + } + + final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate(); + final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + final String transactionId = "DIVROUND" + dividendPayOutId; + + // Approval credited the whole declared amount to Dividend Payable; this takes back the slice + // that will never be paid to a member and returns it to retained profit. + postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId, + sweptDate, + "FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId); + + jdbcTemplate.update(""" + UPDATE m_share_dividend_rounding_residue + SET swept_to_profit_date = ?, gl_transaction_id = ? + WHERE dividend_pay_out_id = ? AND swept_to_profit_date IS NULL + """, sweptDate, transactionId, dividendPayOutId); + } + + /** + * A co-operative society may only distribute what is left of net profit after the statutory appropriations the + * Co-operative Societies Act requires first — a Statutory Reserve Fund transfer and a state-federation + * education-fund contribution — and after every dividend already declared against an overlapping period for ANY + * share product, since the profit pool is society-wide rather than per-product. Without this, two products could + * each be declared 60% of profit and the society would have committed 120% of money it never had. + * + * The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is + * enforced here because that screen is not the only way a declaration can be created, and a stale screen must not + * be able to commit an over-declaration. + * + * Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has + * an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other + * declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor. + */ + private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate, + final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) { + final java.sql.Date from = java.sql.Date.valueOf(dividendPeriodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(dividendPeriodEndDate); + + final BigDecimal netProfit = profitForPeriod(dividendPeriodStartDate, dividendPeriodEndDate, from, to); + + if (netProfit.signum() <= 0) { + throw new DividentProcessingException("no.distributable.profit", "The bank made no profit between " + dividendPeriodStartDate + + " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period."); + } + + final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE); + final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE); + final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund); + + // Every product competes for the same distributable profit, so this deliberately does NOT + // filter by product_id — unlike validateNoOverlappingDividendPeriod, which is about paying + // the same product's members twice for the same days. + final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out + WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ? + """, BigDecimal.class, to, from); + + final BigDecimal available = distributableProfit.subtract(alreadyDeclared); + if (dividendAmount.compareTo(available) > 0) { + throw new DividentProcessingException("exceeds.distributable.profit", + "This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit + + " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves " + + distributableProfit + ", of which " + alreadyDeclared + " is already declared — only " + + available.max(BigDecimal.ZERO) + " remains available."); + } + } + + /** + * The profit a dividend for this period may be declared out of. + * + * For a full financial year that has been closed, that is the balance of the Bank Profit equity GL — the audited + * figure the books settled on once every income and expense GL was closed into it. Reading the closed figure + * rather than re-deriving income-less-expense keeps the declaration tied to the books: an adjusted or reversed + * closing entry moves the dividend ceiling with it. + * + * Anything else is an interim declaration inside a year that is still open, where the live P&L is the only figure + * there is. Mirrors {@code DistributableProfitApiResource} so the screen's ceiling and this one cannot diverge. + */ + private BigDecimal profitForPeriod(final LocalDate periodStart, final LocalDate periodEnd, final java.sql.Date from, + final java.sql.Date to) { + final int fyStartYear = periodStart.getMonthValue() >= 4 ? periodStart.getYear() : periodStart.getYear() - 1; + final String financialYear = fyStartYear + "-" + String.format("%02d", (fyStartYear + 1) % 100); + final LocalDate fyStart = LocalDate.of(fyStartYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(fyStartYear + 1, 3, 31); + + if (!periodStart.isAfter(fyStart) && !periodEnd.isBefore(fyEnd)) { + final Integer closedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear); + if (closedCount != null && closedCount > 0) { + final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + return closedProfit == null ? BigDecimal.ZERO : closedProfit; + } + } + + final Map profitRow = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount + WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount + ELSE 0 END), 0) AS total_income, + COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount + WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount + ELSE 0 END), 0) AS total_expense + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, from, to); + return ((BigDecimal) profitRow.get("total_income")).subtract((BigDecimal) profitRow.get("total_expense")); + } + + /** + * The Indian financial year a date falls in, as "2026-27". A dividend is labelled by the year its period closes, + * which is how a member and an auditor both refer to it. + */ + private String financialYearOf(final LocalDate date) { + final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + private BigDecimal percentageOfNetProfit(final BigDecimal netProfit, final String configurationName) { + final List> rows = jdbcTemplate + .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return BigDecimal.ZERO; + } + final BigDecimal percentage = new BigDecimal(String.valueOf(rows.get(0).get("value"))); + return netProfit.multiply(percentage).divide(BigDecimal.valueOf(100), 6, java.math.RoundingMode.HALF_UP); + } + + /** + * Posts the statutory appropriations that must come out of profit before a dividend may be declared. + * + *
+     *   Dr  Bank Profit               Cr  Statutory Reserve Fund   (25% of net profit, equity — retained permanently)
+     *   Dr  Bank Profit               Cr  Education Fund Payable   (1%, a liability owed to the state federation)
+     * 
+ * + * Until now these only ever reduced the ceiling the declaration screen enforced — the money was never actually + * moved, so the Statutory Reserve Fund and Education Fund GLs stayed empty and the balance sheet did not show the + * transfers the Co-operative Societies Act requires. Posting them here makes the appropriation real. + * + * The rates come from c_configuration (changelog 0478), so a society on a different state Act, or one whose rates + * are revised, changes the setting rather than the code. A disabled or missing row means that appropriation does not + * apply and nothing is posted for it. + * + * Posted once per declaration and only for the appropriations that resolve — a society whose GL setup is incomplete + * still gets its dividend declared rather than being blocked. + */ + private void postStatutoryAppropriations(final Long dividendPayOutId, final String currencyCode, final LocalDate periodStartDate, + final LocalDate periodEndDate, final String financialYear) { + + final java.sql.Date from = java.sql.Date.valueOf(periodStartDate); + final java.sql.Date to = java.sql.Date.valueOf(periodEndDate); + final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to); + if (netProfit.signum() <= 0) { + return; + } + + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); + if (bankProfitAccountId == null) { + return; + } + + postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(STATUTORY_RESERVE_GL_CODE), + percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE), currencyCode, financialYear, + "statutory reserve", "DIVSTATRES"); + postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(EDUCATION_FUND_GL_CODE), + percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE), currencyCode, financialYear, + "education fund", "DIVEDUFUND"); + } + + /** One appropriation out of profit: debit Bank Profit, credit the fund it is set aside into. */ + private void postAppropriation(final Long dividendPayOutId, final Long bankProfitAccountId, final Long fundAccountId, + final BigDecimal amount, final String currencyCode, final String financialYear, final String label, + final String transactionIdPrefix) { + + if (fundAccountId == null || amount.signum() <= 0) { + return; + } + final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + postJournalEntryPair(bankProfitAccountId, fundAccountId, amount, currencyCode, transactionIdPrefix + dividendPayOutId, entryDate, + "FY " + financialYear + " " + label + " appropriated from profit — declaration #" + dividendPayOutId); + } + + private Long glAccountIdByCode(final String glCode) { + return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst() + .orElse(null); + } + + /** + * Declaring a dividend charges it against the society's retained profit and creates the obligation to members. + * + *
+     *   Dr  Bank Profit                 Cr  Reserve for Dividend   — profit set aside for this declaration
+     *   Dr  Reserve for Dividend        Cr  Dividend Payable       — released as what the society owes members
+     * 
+ * + * Routed through Reserve for Dividend rather than posted straight across, so the appropriation of profit and the + * obligation to members are two separate, visible acts — which is how a board minutes them. Reserve nets to nil + * once both legs post; its credit column is the cumulative profit ever set aside for dividends. + * + * The money still starts in Bank Profit and, if never claimed, returns there via Dividend Forfeited Income at + * year-end closing, so the round trip reconciles. + * + * Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which + * expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting + * Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by + * incomplete GL setup. + */ + private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount, + final String financialYear) { + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); + final Long payableDividendsAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) + .stream().findFirst().orElse(null); + final Long reserveAccountId = jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) + .stream().findFirst().orElse(null); + if (bankProfitAccountId == null || payableDividendsAccountId == null) { + return; + } + + final AppUser currentUser = this.context.authenticatedUser(); + final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + final String transactionId = "DIVDECL" + dividendPayOutId; + // The financial year is stamped into every dividend journal entry so the ledger itself can + // answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared + // across years, and without the year on the line the balances only ever give a running total. + final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId; + + if (reserveAccountId == null) { + postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate, + description); + return; + } + + // Only the appropriation posts here. The release to members is a separate act that waits for + // approval — see releaseReserveToDividendPayable. Posting both at declaration would put a + // liability to members on the books before anyone had approved paying them. + postJournalEntryPair(bankProfitAccountId, reserveAccountId, amount, currencyCode, transactionId, sqlEntryDate, + description + " (profit set aside into Reserve)"); + } + + /** + * The second leg of the dividend appropriation, posted on approval: + * + *
+     *   Dr  Reserve for Dividend        Cr  Dividend Payable       — released as what the society owes members
+     * 
+ * + * Split from the declaration leg deliberately. A declared dividend is a proposal: profit is set aside, but the + * society does not yet owe anyone anything. The obligation to members only exists once the board has approved it, + * so Dividend Payable — a liability — must not appear on the balance sheet before that. Between declaration and + * approval the amount sits visibly in Reserve for Dividend, which is exactly what that account is for. + * + * Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside. + */ + private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) { final Long reserveAccountId = jdbcTemplate .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) @@ -223,27 +689,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) .stream().findFirst().orElse(null); + + // With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend + // Payable, so the liability is on the books and there is nothing left to release. if (reserveAccountId == null || payableDividendsAccountId == null) { return; } + // Read back from the row rather than the entity: the amount and product are not exposed as + // getters, and this matches how closeOffUndistributedProfit reads the same declaration. + final Map row = jdbcTemplate.queryForMap( + "SELECT amount, product_id, dividend_period_end_date FROM m_share_product_dividend_pay_out WHERE id = ?", + declaration.getId()); + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount == null || amount.signum() <= 0) { + return; + } + + final ShareProduct product = this.repository.findOneWithNotFoundDetection(((Number) row.get("product_id")).longValue()); + final String financialYear = financialYearOf(((java.sql.Date) row.get("dividend_period_end_date")).toLocalDate()); + final String description = "FY " + financialYear + " dividend approved — declaration #" + declaration.getId() + + " (Reserve released to members)"; + + postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(), + "DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description); + } + + /** + * Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction + * the money travelled and the ledger reads in order. + */ + private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, + final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) { + final AppUser currentUser = this.context.authenticatedUser(); - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); - final String transactionId = "DIVRES" + dividendPayOutId; - final String description = "Reserve for Dividend declaration #" + dividendPayOutId; - final String insertSql = """ - INSERT INTO acc_gl_journal_entry - (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, - description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc, - submitted_on_date, transaction_date) - VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) - """; - // Debit reduces the equity Reserve; credit increases the Dividend Payable liability. - jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description, - currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); + jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, + amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); + jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, + amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); } @Override @@ -256,7 +739,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } dividendPayOutDetails.approveDividendPayout(); this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails); + releaseReserveToDividendPayable(dividendPayOutDetails); + sweepRoundingResidueOnApproval(PayOutDetailId); makeDividendsClaimable(PayOutDetailId); + closeOffUndistributedProfit(dividendPayOutDetails); return new CommandProcessingResultBuilder() // .withEntityId(PayOutDetailId) // .build(); @@ -267,16 +753,14 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account. - * Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at - * declaration time) until the member actually comes to claim it -- at which point it can be - * split between clearing an overdue loan, a savings credit, or cash (see - * {@code DividendClaimRequestApiResource}). This just marks each member's share of the - * declaration POSTED (their entitlement is locked in, no more recalculation) and opens a - * 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather - * than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This - * is cheap enough (no per-member external command calls, just two bulk statements) to run - * synchronously in the approve request itself -- no background job needed. + * Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account. Every member's + * entitlement stays purely a "Dividend Payable" GL liability (already posted at declaration time) until the member + * actually comes to claim it -- at which point it can be split between clearing an overdue loan, a savings credit, + * or cash (see {@code DividendClaimRequestApiResource}). This just marks each member's share of the declaration + * POSTED (their entitlement is locked in, no more recalculation) and opens a 3-year window (see + * {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather than a savings hold) after which an + * unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls, + * just two bulk statements) to run synchronously in the approve request itself -- no background job needed. */ private void makeDividendsClaimable(final Long payOutDetailId) { final LocalDate today = DateUtils.getBusinessLocalDate(); @@ -337,8 +821,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_SHARE, productId, maker); - final java.util.Set submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command, pendingEdit.getShareOnlyFields()); - final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command, pendingEdit.getGlAccountMappingFields()); + final java.util.Set submitted = ProductPendingEditFieldMapper.populateShareOnlyFields(command, + pendingEdit.getShareOnlyFields()); + final boolean glMappingSubmitted = ProductPendingEditFieldMapper.populateGlAccountMapping(command, + pendingEdit.getGlAccountMappingFields()); if (glMappingSubmitted) { submitted.add("glAccountMapping"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java index 3dce44518..25c028700 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java @@ -66,8 +66,8 @@ public class ShareProductsConfiguration { @Bean @ConditionalOnMissingBean(ShareProductDividendAssembler.class) public ShareProductDividendAssembler shareProductDividendAssembler(ShareProductReadPlatformService shareProductReadPlatformService, - ShareAccountReadPlatformService shareAccountReadPlatformService) { - return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService); + ShareAccountReadPlatformService shareAccountReadPlatformService, JdbcTemplate jdbcTemplate) { + return new ShareProductDividendAssembler(shareProductReadPlatformService, shareAccountReadPlatformService, jdbcTemplate); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java new file mode 100644 index 000000000..2c45c36d1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.voucher.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +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 jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Physical verification of a voucher against the paper slip, and the files scanned in alongside it. + * + * The counter reads the signed slip, compares it with what the system recorded, and marks the + * voucher VERIFIED or — when the two disagree — EXCEPTION, carrying the amount the slip actually + * shows and a remark. Whoever investigates then has both figures and the scanned slip in one place, + * instead of having to work out whether the system posted the wrong amount or the slip was written + * up wrongly. + */ +@Path("/v1/vouchers") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class VoucherVerificationApiResource { + + private static final String STATUS_VERIFIED = "VERIFIED"; + private static final String STATUS_EXCEPTION = "EXCEPTION"; + + /** Guards against a scanned slip large enough to strain the request or the row. */ + private static final long MAX_ATTACHMENT_BYTES = 10L * 1024 * 1024; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ── POST /v1/vouchers/{voucherNo}/verify ────────────────────────────────── + // The voucher number travels in the body, not the path: numbers are formatted + // CR/001/2108/0001 and Spring Security's strict firewall rejects the encoded slashes. + + @POST + @Path("verify") + @Consumes(MediaType.APPLICATION_JSON) + public String verify(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final var root = this.fromJsonHelper.parse(body); + + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + + String status = this.fromJsonHelper.extractStringNamed("status", root); + status = status == null ? "" : status.trim().toUpperCase(); + if (!STATUS_VERIFIED.equals(status) && !STATUS_EXCEPTION.equals(status)) { + throw new PlatformDataIntegrityException("error.msg.voucher.verify.status.invalid", + "Status must be either VERIFIED or EXCEPTION."); + } + + final BigDecimal slipAmount = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("slipAmount", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + + // An exception always has to say why, because it is a flag someone else has to act on. + // It does not always mean the amounts differ: a voucher whose figures agree can still be + // wrong — an unsigned slip, the wrong account, a member who disputes the entry — so the + // slip amount is recorded rather than demanded as proof of a difference. + if (STATUS_EXCEPTION.equals(status) && (remarks == null || remarks.isBlank())) { + throw new PlatformDataIntegrityException("error.msg.voucher.verify.remarks.required", + "A remark is required when raising an exception, so it can be investigated."); + } + + final BigDecimal systemAmount = findVoucherAmount(voucherNo); + if (systemAmount == null) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in any register."); + } + + // Upsert: a voucher carries exactly one verdict, so re-checking a slip corrects the earlier + // one rather than leaving two rows to disagree with each other. + jdbcTemplate.update(""" + INSERT INTO m_voucher_verification + (voucher_no, status, system_amount, slip_amount, remarks, verified_by, verified_by_name, verified_at) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT (voucher_no) DO UPDATE SET + status = EXCLUDED.status, + system_amount = EXCLUDED.system_amount, + slip_amount = EXCLUDED.slip_amount, + remarks = EXCLUDED.remarks, + verified_by = EXCLUDED.verified_by, + verified_by_name = EXCLUDED.verified_by_name, + verified_at = EXCLUDED.verified_at + """, + voucherNo, status, systemAmount, + STATUS_EXCEPTION.equals(status) ? slipAmount : null, + remarks, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + + final JsonObject res = new JsonObject(); + res.addProperty("voucherNo", voucherNo); + res.addProperty("status", status); + res.addProperty("systemAmount", systemAmount); + // Only reported when a slip figure was actually supplied — an exception raised over + // something other than the amount carries none, and there is no difference to state. + if (STATUS_EXCEPTION.equals(status) && slipAmount != null) { + res.addProperty("slipAmount", slipAmount); + res.addProperty("difference", slipAmount.subtract(systemAmount)); + } + return gson.toJson(res); + } + + // ── Exception queue, for whoever has to sort them out ───────────────────── + + /** + * Open exceptions across every voucher, newest first — the queue a clerk or manager works + * through. Not scoped to a single day: an exception raised on Friday still needs answering on + * Monday, so limiting this to today would quietly hide the ones that matter most. + * + * Each row carries both amounts, the cashier's remark and the account it belongs to, so whoever + * picks it up can sit down with the cashier already knowing what to look at. + */ + @GET + @Path("exceptions") + public String exceptions(@QueryParam("status") final String status) { + final AppUser currentUser = this.context.authenticatedUser(); + + // Anyone who can see a voucher can see that it was questioned; only a checker may close it. + final boolean openOnly = status == null || status.isBlank() || "OPEN".equalsIgnoreCase(status); + final String resolutionFilter = openOnly ? " AND v.resolution_status IS NULL" + : ("RESOLVED".equalsIgnoreCase(status) ? " AND v.resolution_status IS NOT NULL" : ""); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT v.voucher_no, v.system_amount, v.slip_amount, v.remarks, + v.verified_by_name, v.verified_at, + v.resolution_status, v.resolution_notes, v.resolved_by_name, v.resolved_at, + COALESCE(d.account_no, w.account_no, b.gl_account_no) AS account_no, + COALESCE(d.client_name, w.client_name, b.gl_account_name) AS party, + COALESCE(d.cashier_name, w.clerk_name, b.clerk_name) AS raised_against, + COALESCE(va.n, 0) AS attachment_count + FROM m_voucher_verification v + LEFT JOIN m_cashier_deposit_request d ON d.voucher_no = v.voucher_no + LEFT JOIN m_clerk_withdrawal_request w ON w.voucher_no = v.voucher_no + LEFT JOIN m_clerk_disbursement_request b ON b.voucher_no = v.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = v.voucher_no + WHERE v.status = 'EXCEPTION'""" + resolutionFilter + """ + ORDER BY v.verified_at DESC + """); + + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", r.get("voucher_no")); + m.put("systemAmount", r.get("system_amount")); + m.put("slipAmount", r.get("slip_amount")); + m.put("remarks", r.get("remarks")); + m.put("raisedByName", r.get("verified_by_name")); + m.put("raisedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); + m.put("accountNo", r.get("account_no")); + m.put("party", r.get("party")); + m.put("raisedAgainst", r.get("raised_against")); + m.put("attachmentCount", r.get("attachment_count")); + m.put("resolutionStatus", r.get("resolution_status")); + m.put("resolutionNotes", r.get("resolution_notes")); + m.put("resolvedByName", r.get("resolved_by_name")); + m.put("resolvedAt", r.get("resolved_at") == null ? null : r.get("resolved_at").toString()); + out.add(m); + } + return gson.toJson(Map.of("canResolve", isCheckerRole(currentUser), "exceptions", out, "count", out.size())); + } + + /** + * Closes an exception with a conclusion. Open to a clerk, head cashier or manager — but not to + * the cashier who raised the flag, who must not be the one who decides it was nothing. + */ + @POST + @Path("exceptions/resolve") + @Consumes(MediaType.APPLICATION_JSON) + public String resolveException(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + if (!isCheckerRole(currentUser)) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolve.forbidden", + "Only a Clerk, Head Cashier or Bank Manager can close a voucher exception."); + } + + final var root = this.fromJsonHelper.parse(body); + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + String resolution = this.fromJsonHelper.extractStringNamed("resolutionStatus", root); + final String notes = this.fromJsonHelper.extractStringNamed("resolutionNotes", root); + + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + resolution = resolution == null ? "" : resolution.trim().toUpperCase(); + if (!List.of("RESOLVED_SYSTEM_ERROR", "RESOLVED_SLIP_ERROR", "RESOLVED_NO_ACTION").contains(resolution)) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.resolution.invalid", + "Choose whether the system entry was wrong, the slip was wrong, or no action is needed."); + } + if (notes == null || notes.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.notes.required", + "Record what was found and what was done about it."); + } + + // Closing an exception needs paper behind it — the corrected slip, a signed note, whatever + // was relied on. Without that the resolution is one person's word, which is precisely what + // the exception was raised to avoid. Upload happens first, then the close. + final Integer attachments = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_voucher_attachment WHERE voucher_no = ?", Integer.class, voucherNo); + if (attachments == null || attachments == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.document.required", + "Attach the supporting document for this resolution before closing the exception."); + } + + final int updated = jdbcTemplate.update(""" + UPDATE m_voucher_verification + SET resolution_status = ?, resolution_notes = ?, resolved_by = ?, + resolved_by_name = ?, resolved_at = ? + WHERE voucher_no = ? AND status = 'EXCEPTION' + """, + resolution, notes, currentUser.getId(), currentUser.getDisplayName(), + Timestamp.from(Instant.now()), voucherNo); + + if (updated == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.exception.not.found", + "No open exception found for voucher " + voucherNo + "."); + } + return gson.toJson(Map.of("voucherNo", voucherNo, "resolutionStatus", resolution, "resolved", true)); + } + + /** + * Who may close an exception: a clerk, head cashier or manager. The clerk is included because + * most differences are settled at the counter between the two of them — sending every one up to + * a manager would leave routine keying slips sitting open for days. + * + * The cashier who raised the flag is still not among them: whoever spots a difference should not + * also be the one who rules it was nothing. + */ + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk")); + } + + // There is deliberately no endpoint to clear a verdict. Deleting one would delete the evidence + // that a voucher was ever questioned, which is the opposite of what an investigation needs — a + // wrong verdict is corrected by re-verifying (the upsert above) or by raising an exception + // against it, both of which leave a record of who decided what. + + // ── Attachments ─────────────────────────────────────────────────────────── + + /** + * Accepts one scanned slip as base64 in a JSON body rather than a multipart upload: the + * browser already holds the file as a data URL from the file input, and this keeps the call on + * the same JSON path as every other request the screen makes. + */ + @POST + @Path("attachments") + @Consumes(MediaType.APPLICATION_JSON) + public String upload(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final var root = this.fromJsonHelper.parse(body); + + final String voucherNo = this.fromJsonHelper.extractStringNamed("voucherNo", root); + final String fileName = this.fromJsonHelper.extractStringNamed("fileName", root); + final String contentType = this.fromJsonHelper.extractStringNamed("contentType", root); + final String description = this.fromJsonHelper.extractStringNamed("description", root); + String base64 = this.fromJsonHelper.extractStringNamed("fileData", root); + + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + if (fileName == null || fileName.isBlank() || base64 == null || base64.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.file.required", + "A file name and file contents are required."); + } + if (findVoucherAmount(voucherNo) == null) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in any register."); + } + + // A file input hands over "data:image/png;base64,AAAA..." — keep only the payload. + final int comma = base64.indexOf(','); + if (base64.startsWith("data:") && comma > 0) { + base64 = base64.substring(comma + 1); + } + + final byte[] data; + try { + data = java.util.Base64.getDecoder().decode(base64); + } catch (final IllegalArgumentException e) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.invalid", + "The file could not be read. Please try uploading it again."); + } + if (data.length == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.empty", "The file is empty."); + } + if (data.length > MAX_ATTACHMENT_BYTES) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.too.large", + "The file is larger than 10 MB. Please scan it at a lower resolution."); + } + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_attachment + (voucher_no, file_name, content_type, file_size, description, file_data, + uploaded_by, uploaded_by_name, uploaded_at) + VALUES (?,?,?,?,?,?,?,?,?) + RETURNING id + """, + Long.class, voucherNo, fileName, contentType, (long) data.length, description, data, + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + + return gson.toJson(Map.of("resourceId", id, "voucherNo", voucherNo, "fileName", fileName, "fileSize", data.length)); + } + + /** Metadata only — the bytes are fetched one at a time by {@link #download}. */ + @GET + @Path("attachments") + public String list(@QueryParam("voucherNo") final String voucherNo) { + this.context.authenticatedUser(); + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, voucher_no, file_name, content_type, file_size, description, + uploaded_by_name, uploaded_at + FROM m_voucher_attachment + WHERE voucher_no = ? + ORDER BY id DESC + """, voucherNo); + + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("id", r.get("id")); + m.put("voucherNo", r.get("voucher_no")); + m.put("fileName", r.get("file_name")); + m.put("contentType", r.get("content_type")); + m.put("fileSize", r.get("file_size")); + m.put("description", r.get("description")); + m.put("uploadedByName", r.get("uploaded_by_name")); + final Object at = r.get("uploaded_at"); + m.put("uploadedAt", at == null ? null : at.toString()); + out.add(m); + } + return gson.toJson(Map.of("voucherNo", voucherNo, "attachments", out)); + } + + @GET + @Path("attachments/{id}/download") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response download(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate + .queryForList("SELECT file_name, content_type, file_data FROM m_voucher_attachment WHERE id = ?", id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found", + "Attachment " + id + " does not exist."); + } + final Map row = rows.get(0); + final byte[] data = (byte[]) row.get("file_data"); + final String contentType = row.get("content_type") != null ? (String) row.get("content_type") + : MediaType.APPLICATION_OCTET_STREAM; + return Response.ok(data, contentType) + .header("Content-Disposition", "attachment; filename=\"" + row.get("file_name") + "\"").build(); + } + + @DELETE + @Path("attachments/{id}") + public String delete(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + final int removed = jdbcTemplate.update("DELETE FROM m_voucher_attachment WHERE id = ?", id); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.voucher.attachment.not.found", + "Attachment " + id + " does not exist."); + } + return gson.toJson(Map.of("resourceId", id, "deleted", true)); + } + + /** + * The amount the system holds for a voucher, looked up across all three registers. Returns + * null when no register knows the number, which is how callers detect a bad voucher. + */ + private BigDecimal findVoucherAmount(final String voucherNo) { + final List found = jdbcTemplate.query(""" + SELECT amount FROM m_cashier_deposit_request WHERE voucher_no = ? + UNION ALL + SELECT amount FROM m_clerk_withdrawal_request WHERE voucher_no = ? + UNION ALL + SELECT dr_amount FROM m_clerk_disbursement_request WHERE voucher_no = ? + """, (rs, n) -> rs.getBigDecimal(1), voucherNo, voucherNo, voucherNo); + return found.isEmpty() ? null : found.get(0); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index c243e9034..32bb2cbd8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -19,22 +19,35 @@ package org.apache.fineract.portfolio.voucher.service; import java.math.BigDecimal; +import java.sql.Date; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; /** * Central voucher register (m_voucher). Every maker-checker cash transaction - * (clerk withdrawal request, cashier deposit request) gets a globally unique - * voucher number from this single sequence, so a withdrawal and a deposit can - * never share the same voucher number. The voucher is also stamped onto the - * resulting savings transaction (m_savings_account_transaction.voucher_no) - * once the manager approves and the transaction is created. + * (clerk withdrawal request, cashier deposit request) gets a voucher number in + * the form PREFIX/BRANCH/DDMM/NNNN — for example CR/001/2108/0001 for a deposit + * and DR/001/2108/0001 for a withdrawal on 21 August at branch 001. + * + * The trailing counter restarts at 0001 every day, independently for each + * (prefix, branch) pair, so a deposit and a withdrawal at the same branch on the + * same day never share a number, and neither does the same direction at another + * branch. Counters live in m_voucher_sequence and are bumped under a row lock, + * because a daily-reset counter cannot lean on the m_voucher autoincrement PK + * for uniqueness the way the old flat "000001" scheme did. + * + * The voucher is also stamped onto the resulting savings transaction + * (m_savings_account_transaction.voucher_no) once the manager approves and the + * transaction is created. */ @Service @RequiredArgsConstructor @@ -46,30 +59,90 @@ public class VoucherService { public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; + public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; + + /** Money into an account — the CR side of the slip. */ + public static final String PREFIX_CREDIT = "CR"; + /** Money out of an account — the DR side of the slip. */ + public static final String PREFIX_DEBIT = "DR"; + + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; /** - * Creates a voucher row and returns the formatted voucher number ("000001"). - * The number is the zero-padded primary key, so uniqueness is guaranteed by - * the database sequence — no counter table, no race conditions. + * Maps a voucher type to its slip-side prefix. A deposit credits the member's + * account (CR); a withdrawal and a disbursement both take money out (DR). + */ + private static String prefixFor(final String voucherType) { + return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT; + } + + /** Branch code as it appears in the UI's BRANCH dropdown: office id zero-padded to 3 digits. */ + private static String branchCode(final Long officeId) { + return String.format("%03d", officeId != null ? officeId : 1L); + } + + /** + * Reserves the next counter value for one (prefix, branch, day) series. + * + * The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use + * of the day and increments it thereafter. Being a single statement, it takes + * and releases the row lock atomically, so two cashiers submitting at the same + * moment are serialised by the database and can never be handed the same + * number — no explicit SELECT ... FOR UPDATE needed. + */ + private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) { + return jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number) + VALUES (?,?,?,1) + ON CONFLICT (prefix, branch_code, series_date) + DO UPDATE SET last_number = m_voucher_sequence.last_number + 1 + RETURNING last_number + """, + Integer.class, prefix, branch, Date.valueOf(day)); + } + + /** + * Creates a voucher row and returns the formatted voucher number, e.g. + * "CR/001/2108/0001". The branch is taken from the acting user's own office + * rather than from any branch the screen happens to have selected, so the + * voucher always names the branch that actually handled the cash. */ public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user) { + return createVoucher(voucherType, referenceType, referenceId, amount, user, prefixFor(voucherType)); + } - final Long voucherId = jdbcTemplate.queryForObject(""" + /** + * As {@link #createVoucher}, but with the slip side stated explicitly. The + * disbursement register covers three directions under one voucher type — + * money out of a GL (DISBURSEMENT), money into a GL (COLLECTION) and an + * account-to-account move (BANK_TRANSFER) — so the caller decides whether + * this one reads as CR or DR rather than inferring it from the type alone. + */ + public String createVoucher(final String voucherType, final String referenceType, + final Long referenceId, final BigDecimal amount, final AppUser user, + final String prefix) { + + // Tenant timezone, not the server's — the day the counter resets on has to be the + // branch's business day, otherwise a late-evening voucher lands in the wrong series. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null); + final int serial = nextSeriesNumber(prefix, branch, today); + final String voucherNo = String.format("%s/%s/%s/%04d", + prefix, branch, today.format(DAY_MONTH), serial); + + jdbcTemplate.update(""" INSERT INTO m_voucher - (voucher_type, reference_type, reference_id, amount, created_by, created_by_username, created_at) - VALUES (?,?,?,?,?,?,?) - RETURNING id + (voucher_no, voucher_type, reference_type, reference_id, amount, + created_by, created_by_username, created_at) + VALUES (?,?,?,?,?,?,?,?) """, - Long.class, - voucherType, referenceType, referenceId, amount, + voucherNo, voucherType, referenceType, referenceId, amount, user.getId(), user.getUsername(), Timestamp.from(Instant.now()) ); - final String voucherNo = String.format("%06d", voucherId); - jdbcTemplate.update("UPDATE m_voucher SET voucher_no = ? WHERE id = ?", voucherNo, voucherId); return voucherNo; } diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 18fcdd30f..b68204e8a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d2407861e..11ea86186 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -537,4 +537,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml new file mode 100644 index 000000000..608897382 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_add_resolution_date_to_share_account.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml new file mode 100644 index 000000000..a3a1371e6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_make_share_account_savings_optional.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml new file mode 100644 index 000000000..159d475fd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_create_share_advance_collection.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml new file mode 100644 index 000000000..1655d38c0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_add_collection_applied_to_share_account.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml new file mode 100644 index 000000000..e8344dc7d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_voucher_no_to_share_advance_collection.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml new file mode 100644 index 000000000..64a2da40c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_add_share_applied_to_deposit_request.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml new file mode 100644 index 000000000..3484b39c9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_create_income_profit_transfer.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml new file mode 100644 index 000000000..df54c2134 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_share_entry_fee_profit_job.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml new file mode 100644 index 000000000..6ca3d9723 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_seed_dividend_appropriation_accounts_and_config.xml @@ -0,0 +1,116 @@ + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code IN ('STATRES01', 'EDUFUND01') + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name IN ('dividend-statutory-reserve-percentage', + 'dividend-education-fund-percentage', 'dividend-maximum-rate-percentage') + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml new file mode 100644 index 000000000..9d25dd8cd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0479_dividend_claim_by_demand_draft.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml new file mode 100644 index 000000000..ca8b4d3cc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0480_create_dividend_rounding_residue.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-minimum-payable-amount' + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'dividend-rounding-multiple' + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml new file mode 100644 index 000000000..4029b4111 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0481_seed_dividend_paid_gl.xml @@ -0,0 +1,68 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVPAID01' + + + + + + + + + + + + + INSERT INTO acc_gl_financial_activity_account (gl_account_id, financial_activity_type) + SELECT id, 202 FROM acc_gl_account WHERE gl_code = 'DIVPAID01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml new file mode 100644 index 000000000..71e9e7b21 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0482_create_share_savings_debit_request.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CREATE UNIQUE INDEX uq_ssdr_one_pending_per_account + ON m_share_savings_debit_request (share_account_id) + WHERE status = 'PENDING'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml new file mode 100644 index 000000000..9b22d3f68 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0483_seed_share_savings_debit_payment_type.xml @@ -0,0 +1,54 @@ + + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE value = 'Share Application Settlement' + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml new file mode 100644 index 000000000..722d9e079 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0484_voucher_no_dated_format.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_account_transaction SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_clerk_withdrawal_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_cashier_deposit_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_clerk_disbursement_request SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + UPDATE m_share_advance_collection SET voucher_no = NULL WHERE voucher_no ~ '^[0-9]{6}$' + DELETE FROM m_voucher WHERE voucher_no ~ '^[0-9]{6}$' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml new file mode 100644 index 000000000..baad9edd7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0485_backfill_flat_share_charge_amounts.xml @@ -0,0 +1,36 @@ + + + + + + + + UPDATE m_share_account_charge + SET amount = charge_amount_or_percentage, + amount_outstanding_derived = charge_amount_or_percentage, + is_paid_derived = false + WHERE charge_calculation_enum = 1 + AND COALESCE(amount, 0) = 0 + AND COALESCE(charge_amount_or_percentage, 0) > 0 + AND COALESCE(amount_paid_derived, 0) = 0 + AND COALESCE(amount_waived_derived, 0) = 0 + AND COALESCE(amount_writtenoff_derived, 0) = 0 + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml new file mode 100644 index 000000000..5a24ee0b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0486_unpay_unsettled_share_activation_charges.xml @@ -0,0 +1,44 @@ + + + + + + + + UPDATE m_share_account_charge c + SET amount_paid_derived = 0, + amount_outstanding_derived = c.amount, + is_paid_derived = false + WHERE c.charge_time_enum = 13 + AND c.is_active = true + AND c.is_paid_derived = true + AND COALESCE(c.amount, 0) > 0 + AND COALESCE(c.amount_waived_derived, 0) = 0 + AND COALESCE(c.amount_writtenoff_derived, 0) = 0 + AND EXISTS ( + SELECT 1 FROM m_share_account sa + WHERE sa.id = c.account_id + AND COALESCE(sa.collection_applied, false) = false + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml new file mode 100644 index 000000000..cec1d981e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0487_create_voucher_verification.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml new file mode 100644 index 000000000..d9540df3b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0488_add_voucher_exception_resolution.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml new file mode 100644 index 000000000..c6d163e48 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0489_create_branch_day_register.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_BRANCH_OPS' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'MENU_BRANCH_OPS' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) LIKE '%clerk%' + OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml new file mode 100644 index 000000000..d567c843d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0490_seed_bank_profit_non_distributable_gl.xml @@ -0,0 +1,76 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01' + + + + + + + + + + + + + + + + + + gl_code = 'BANKPROFIT01' + + + + From cad7f54c47f8cb18dfa84424098cd87f85a9193b Mon Sep 17 00:00:00 2001 From: Anshul Rathore Date: Wed, 26 Aug 2026 10:38:09 +0530 Subject: [PATCH 09/55] feat: implement maker-checker for product creation, add app user preferences, and introduce base interest rate management services. --- .../service/CommandSourceService.java | 18 +- .../service/CommandWrapperBuilder.java | 8 + ...tProductsBaseInterestRatesApiResource.java | 17 +- ...eateCbsBaseInterestRateCommandHandler.java | 42 ++ ...seInterestRateReadPlatformServiceImpl.java | 129 +++++- ...dBaseInterestRateWritePlatformService.java | 27 ++ ...eInterestRateWritePlatformServiceImpl.java | 49 +++ .../savings/starter/SavingsConfiguration.java | 10 + .../api/AppUserPreferencesApiResource.java | 79 ++++ .../data/AppUserPreferenceData.java | 13 + .../domain/AppUserPreference.java | 54 +++ .../domain/AppUserPreferenceRepository.java | 10 + .../AppUserPreferenceReadPlatformService.java | 5 + ...UserPreferenceReadPlatformServiceImpl.java | 20 + ...AppUserPreferenceWritePlatformService.java | 5 + ...serPreferenceWritePlatformServiceImpl.java | 32 ++ .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 6 + .../0466_create_m_appuser_preference.xml | 24 ++ .../0467_enable_glaccount_maker_checker.xml | 23 + ...add_address_id_to_welcome_kit_dispatch.xml | 1 + .../0469_create_database_export_request.xml | 2 + ...e_cbs_base_interest_rate_maker_checker.xml | 68 +++ .../0471_enable_charge_maker_checker.xml | 63 +++ ..._enable_product_creation_maker_checker.xml | 87 ++++ ...le_fixed_deposit_product_maker_checker.xml | 72 ++++ .../service/CommandSourceServiceTest.java | 392 ++++++++++++++++++ ...ateChargeDefinitionCommandHandlerTest.java | 60 +++ .../CreateLoanProductCommandHandlerTest.java | 60 +++ ...CbsBaseInterestRateCommandHandlerTest.java | 60 +++ ...reateSavingsProductCommandHandlerTest.java | 60 +++ ...reateCurrentProductCommandHandlerTest.java | 60 +++ ...terestRateReadPlatformServiceImplTest.java | 265 ++++++++++++ ...erestRateWritePlatformServiceImplTest.java | 80 ++++ .../CreateShareProductCommandHandlerTest.java | 60 +++ 35 files changed, 1955 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/api/AppUserPreferencesApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/data/AppUserPreferenceData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreference.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreferenceRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_create_m_appuser_preference.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_enable_glaccount_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_enable_cbs_base_interest_rate_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_enable_charge_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_enable_product_creation_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_enable_fixed_deposit_product_maker_checker.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/charge/handler/CreateChargeDefinitionCommandHandlerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/handler/CreateLoanProductCommandHandlerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandlerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateSavingsProductCommandHandlerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/current/CreateCurrentProductCommandHandlerTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImplTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImplTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/shareproducts/handler/CreateShareProductCommandHandlerTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 75840388d..ea8a7294d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -137,7 +137,7 @@ public class CommandSourceService { String permission = commandSource.getPermissionCode(); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); if (isMakerChecker || result.isRollbackTransaction()) { - if (isApprovedByChecker || user.isCheckerSuperUser()) { + if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) { commandSource.markAsChecked(user); } else { if (commandSource.isSanitized()) { @@ -151,6 +151,22 @@ public class CommandSourceService { return result; } + private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) { + if (!user.isCheckerSuperUser()) { + return false; + } + if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName()) + || "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) + || "CHARGE".equalsIgnoreCase(commandSource.getEntityName()) + || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())) { + return false; + } + return true; + } + private void sanitizeJson(@NonNull CommandSource commandSource, Set sanitizeKeys) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { return; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 5e2c31402..44288c368 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createCbsBaseInterestRate() { + this.actionName = "CREATE"; + this.entityName = "CBSBASEINTERESTRATE"; + this.entityId = null; + this.href = "/fixeddepositproducts/baseinterestrates"; + return this; + } + public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) { this.actionName = "UPDATE"; this.entityName = "FIXEDDEPOSITPRODUCT"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsBaseInterestRatesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsBaseInterestRatesApiResource.java index f740409c4..fd5badcf3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsBaseInterestRatesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositProductsBaseInterestRatesApiResource.java @@ -29,6 +29,10 @@ import jakarta.ws.rs.core.MediaType; import java.time.LocalDate; import java.util.List; 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.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; @@ -42,6 +46,7 @@ public class FixedDepositProductsBaseInterestRatesApiResource { private final PlatformSecurityContext context; private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new GsonBuilder() .registerTypeAdapter(LocalDate.class, (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString())) @@ -77,11 +82,11 @@ public class FixedDepositProductsBaseInterestRatesApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public String saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) { - this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME); - final FdInterestRateSaveRequest request = gson.fromJson(apiRequestBodyAsJson, FdInterestRateSaveRequest.class); - final Long createdById = this.context.authenticatedUser().getId(); - final FdInterestRateEffectiveData saved = this.fdBaseInterestRateReadPlatformService.saveInterestRates(request, createdById); - return gson.toJson(saved); + public CommandProcessingResult saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .createCbsBaseInterestRate() + .withJson(apiRequestBodyAsJson) + .build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandler.java new file mode 100644 index 000000000..3d4673fb5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.handler; + +import lombok.RequiredArgsConstructor; +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.savings.service.FdBaseInterestRateWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE") +@RequiredArgsConstructor +public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler { + + private final FdBaseInterestRateWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.saveInterestRates(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java index 516eb97c8..c7b0acd63 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImpl.java @@ -11,10 +11,16 @@ */ package org.apache.fineract.portfolio.savings.service; +import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest; import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; @@ -51,7 +57,9 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest @Override @Transactional public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) { - final String fdType = request.getFdType(); + validateRequest(request); + + final String fdType = request.getFdType().toUpperCase(); final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final List activeVersions = effectiveRepository @@ -85,6 +93,125 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest return toData(newVersion); } + private void validateRequest(final FdInterestRateSaveRequest request) { + final List errors = new ArrayList<>(); + final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION"); + + if (request == null) { + validator.failWithCodeNoParameterAddedToErrorCode("request.cannot.be.null"); + throwExceptionIfValidationWarningsExist(errors); + return; + } + + final String fdType = request.getFdType(); + if (fdType == null || fdType.isBlank()) { + validator.parameter("fdType").failWithCode("cannot.be.blank"); + } else if (!List.of("SINGLE", "DOUBLE", "SPECIAL").contains(fdType.toUpperCase())) { + validator.parameter("fdType").value(fdType).failWithCode("invalid.fd.type"); + } + + if (request.getSlabs() == null || request.getSlabs().isEmpty()) { + validator.parameter("slabs").failWithCode("cannot.be.blank"); + } + + if (request.getVersionLabel() != null && request.getVersionLabel().length() > 100) { + validator.parameter("versionLabel").value(request.getVersionLabel()).failWithCode("exceeds.max.length"); + } + + throwExceptionIfValidationWarningsExist(errors); + validateSlabs(request.getSlabs()); + } + + private void validateSlabs(final List slabs) { + final List errors = new ArrayList<>(); + final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION"); + + if (slabs == null || slabs.isEmpty()) { + validator.parameter("slabs").failWithCode("cannot.be.blank"); + throwExceptionIfValidationWarningsExist(errors); + return; + } + + final List sorted = new ArrayList<>(slabs); + sorted.sort(Comparator.comparing((FdInterestRateSaveRequest.SlabRequest slab) -> slab.getSrNo() == null ? Integer.MAX_VALUE : slab.getSrNo()) + .thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod())); + + final Integer expectedPeriodType = sorted.get(0).getPeriodTypeEnum(); + Integer expectedFrom = 1; + + for (int i = 0; i < sorted.size(); i++) { + final FdInterestRateSaveRequest.SlabRequest slab = sorted.get(i); + final int srNo = slab.getSrNo() == null ? i + 1 : slab.getSrNo(); + final String slabPrefix = "slabs[" + srNo + "]."; + + if (slab.getPeriodTypeEnum() == null) { + validator.parameter(slabPrefix + "periodTypeEnum").failWithCode("cannot.be.blank"); + } else if (!slab.getPeriodTypeEnum().equals(expectedPeriodType)) { + validator.parameter(slabPrefix + "periodTypeEnum").value(slab.getPeriodTypeEnum()) + .failWithCode("must.be.consistent.across.all.slabs"); + } + + final Integer fromPeriod = slab.getFromPeriod(); + if (fromPeriod == null) { + validator.parameter(slabPrefix + "fromPeriod").failWithCode("cannot.be.blank"); + } else if (fromPeriod < 0) { + validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.non.negative"); + } else if (!fromPeriod.equals(expectedFrom)) { + if (i == 0) { + validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("first.slab.must.start.at.one"); + } else if (fromPeriod < expectedFrom) { + validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.not.overlap.previous.range"); + } else { + validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.continuous.with.previous.range"); + } + } + + final Integer toPeriod = slab.getToPeriod(); + if (i < sorted.size() - 1) { + if (toPeriod == null) { + validator.parameter(slabPrefix + "toPeriod").failWithCode("cannot.be.blank.for.intermediate.slab"); + } else { + if (toPeriod < 0) { + validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative"); + } + if (fromPeriod != null && toPeriod < fromPeriod) { + validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod"); + } + expectedFrom = toPeriod + 1; + } + } else { + if (toPeriod != null) { + if (toPeriod < 0) { + validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative"); + } + if (fromPeriod != null && toPeriod < fromPeriod) { + validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod"); + } + } + } + + final BigDecimal annualRate = slab.getAnnualInterestRate(); + if (annualRate == null) { + validator.parameter(slabPrefix + "annualInterestRate").failWithCode("cannot.be.blank"); + } else if (annualRate.compareTo(BigDecimal.ZERO) < 0) { + validator.parameter(slabPrefix + "annualInterestRate").value(annualRate).failWithCode("must.be.non.negative"); + } + + final BigDecimal seniorRate = slab.getSeniorCitizenRate(); + if (seniorRate != null && seniorRate.compareTo(BigDecimal.ZERO) < 0) { + validator.parameter(slabPrefix + "seniorCitizenRate").value(seniorRate).failWithCode("must.be.non.negative"); + } + } + + throwExceptionIfValidationWarningsExist(errors); + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) { List slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream() .map(s -> FdInterestRateSlabData.builder() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformService.java new file mode 100644 index 000000000..100138c83 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformService.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface FdBaseInterestRateWritePlatformService { + + CommandProcessingResult saveInterestRates(JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImpl.java new file mode 100644 index 000000000..eb239c191 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImpl.java @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import com.google.gson.Gson; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest; +import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FdBaseInterestRateWritePlatformServiceImpl implements FdBaseInterestRateWritePlatformService { + + private final PlatformSecurityContext context; + private final FdBaseInterestRateReadPlatformService readPlatformService; + private final Gson gson = new Gson(); + + @Override + public CommandProcessingResult saveInterestRates(final JsonCommand command) { + final FdInterestRateSaveRequest request = gson.fromJson(command.json(), FdInterestRateSaveRequest.class); + final Long createdById = this.context.authenticatedUser().getId(); + final FdInterestRateEffectiveData saved = this.readPlatformService.saveInterestRates(request, createdById); + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withEntityId(saved.getId()) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 722d25070..e1aad5f58 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -169,6 +169,8 @@ import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatf import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster; import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask; import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService; @@ -200,6 +202,14 @@ public class SavingsConfiguration { return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository); } + @Bean + @ConditionalOnMissingBean(FdBaseInterestRateWritePlatformService.class) + public FdBaseInterestRateWritePlatformService fdBaseInterestRateWritePlatformService( + PlatformSecurityContext context, + FdBaseInterestRateReadPlatformService readPlatformService) { + return new FdBaseInterestRateWritePlatformServiceImpl(context, readPlatformService); + } + @Bean @ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class) public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context, diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/AppUserPreferencesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/AppUserPreferencesApiResource.java new file mode 100644 index 000000000..0ecd065ad --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/AppUserPreferencesApiResource.java @@ -0,0 +1,79 @@ +package org.apache.fineract.useradministration.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +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 lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.data.AppUserPreferenceData; +import org.apache.fineract.useradministration.service.AppUserPreferenceReadPlatformService; +import org.apache.fineract.useradministration.service.AppUserPreferenceWritePlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/users/{userId}/preferences") +@Component +@Tag(name = "User Preferences", description = "API to manage user specific preferences.") +@RequiredArgsConstructor +public class AppUserPreferencesApiResource { + + private final PlatformSecurityContext context; + private final AppUserPreferenceReadPlatformService readPlatformService; + private final AppUserPreferenceWritePlatformService writePlatformService; + + private void validateUserAuthorization(Long userId) { + if (!context.authenticatedUser().getId().equals(userId)) { + throw new NoAuthorizationException("Not authorized to access or modify preferences for another user."); + } + } + + @GET + @Path("{preferenceName}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve user preference", description = "Retrieves a specific preference for the user.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) }) + public AppUserPreferenceData getPreference(@PathParam("userId") @Parameter(description = "userId") final Long userId, + @PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName) { + + validateUserAuthorization(userId); + String value = readPlatformService.getPreferenceValue(userId, preferenceName); + return new AppUserPreferenceData(preferenceName, value); + } + + @PUT + @Path("{preferenceName}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update user preference", description = "Updates or creates a specific preference for the user.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public AppUserPreferenceData updatePreference(@PathParam("userId") @Parameter(description = "userId") final Long userId, + @PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName, + final AppUserPreferenceData preferenceData) { + + validateUserAuthorization(userId); + + // Ensure the path parameter matches the request body if the request body contains a name + String valueToSave = preferenceData != null ? preferenceData.getValue() : null; + if (valueToSave == null) { + throw new IllegalArgumentException("Preference value cannot be null"); + } + + writePlatformService.updatePreference(userId, preferenceName, valueToSave); + return new AppUserPreferenceData(preferenceName, valueToSave); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/AppUserPreferenceData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/AppUserPreferenceData.java new file mode 100644 index 000000000..bcb37e359 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/AppUserPreferenceData.java @@ -0,0 +1,13 @@ +package org.apache.fineract.useradministration.data; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AppUserPreferenceData { + private String name; + private String value; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreference.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreference.java new file mode 100644 index 000000000..3ff371990 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreference.java @@ -0,0 +1,54 @@ +package org.apache.fineract.useradministration.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_appuser_preference") +public class AppUserPreference extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "appuser_id", nullable = false) + private AppUser appUser; + + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Column(name = "value", nullable = false) + private String value; + + protected AppUserPreference() { + // + } + + private AppUserPreference(final AppUser appUser, final String name, final String value) { + this.appUser = appUser; + this.name = name; + this.value = value; + } + + public static AppUserPreference create(final AppUser appUser, final String name, final String value) { + return new AppUserPreference(appUser, name, value); + } + + public void updateValue(final String newValue) { + this.value = newValue; + } + + public String getName() { + return this.name; + } + + public String getValue() { + return this.value; + } + + public AppUser getAppUser() { + return this.appUser; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreferenceRepository.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreferenceRepository.java new file mode 100644 index 000000000..52279af43 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserPreferenceRepository.java @@ -0,0 +1,10 @@ +package org.apache.fineract.useradministration.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AppUserPreferenceRepository extends JpaRepository { + + Optional findByAppUserIdAndName(Long appUserId, String name); + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformService.java new file mode 100644 index 000000000..0973b1c4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformService.java @@ -0,0 +1,5 @@ +package org.apache.fineract.useradministration.service; + +public interface AppUserPreferenceReadPlatformService { + String getPreferenceValue(Long appUserId, String name); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformServiceImpl.java new file mode 100644 index 000000000..bb2c46d54 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceReadPlatformServiceImpl.java @@ -0,0 +1,20 @@ +package org.apache.fineract.useradministration.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.domain.AppUserPreference; +import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AppUserPreferenceReadPlatformServiceImpl implements AppUserPreferenceReadPlatformService { + + private final AppUserPreferenceRepository appUserPreferenceRepository; + + @Override + public String getPreferenceValue(Long appUserId, String name) { + Optional pref = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name); + return pref.map(AppUserPreference::getValue).orElse(null); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformService.java new file mode 100644 index 000000000..d32f3ed46 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformService.java @@ -0,0 +1,5 @@ +package org.apache.fineract.useradministration.service; + +public interface AppUserPreferenceWritePlatformService { + void updatePreference(Long appUserId, String name, String value); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformServiceImpl.java new file mode 100644 index 000000000..2ca6ff41e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserPreferenceWritePlatformServiceImpl.java @@ -0,0 +1,32 @@ +package org.apache.fineract.useradministration.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserPreference; +import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AppUserPreferenceWritePlatformServiceImpl implements AppUserPreferenceWritePlatformService { + + private final AppUserPreferenceRepository appUserPreferenceRepository; + private final AppUserRepository appUserRepository; + + @Transactional + @Override + public void updatePreference(Long appUserId, String name, String value) { + AppUserPreference preference = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name).orElse(null); + if (preference == null) { + AppUser appUser = appUserRepository.findById(appUserId) + .orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.id.invalid", "User not found")); + preference = AppUserPreference.create(appUser, name, value); + } else { + preference.updateValue(value); + } + appUserPreferenceRepository.saveAndFlush(preference); + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index e39a6cdd6..3e57e55b5 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d2407861e..8fd705c9f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -537,4 +537,10 @@ + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_create_m_appuser_preference.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_create_m_appuser_preference.xml new file mode 100644 index 000000000..f2065b1b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_create_m_appuser_preference.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_enable_glaccount_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_enable_glaccount_maker_checker.xml new file mode 100644 index 000000000..9fee7092f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_enable_glaccount_maker_checker.xml @@ -0,0 +1,23 @@ + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE entity_name = 'GLACCOUNT' + AND action_name IN ('CREATE', 'UPDATE', 'DELETE') + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE entity_name = 'GLACCOUNT' + AND action_name IN ('CREATE', 'UPDATE', 'DELETE'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml index d440dcb0d..852fbddff 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml @@ -7,6 +7,7 @@ address as verified or not verified once the outcome is known. Deliberately a plain column rather than a foreign key: the dispatch is a historical record and must survive the address being edited or removed. --> + ANY diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml index b373481da..e6892667a 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml @@ -7,6 +7,7 @@ person's authority. The requester states a reason, an admin approves or rejects, and only an approved request can produce a dump. Every step is stamped so the trail survives the file itself. --> + ANY @@ -50,6 +51,7 @@ + ANY INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) SELECT 'authorisation', 'CREATE_DATABASEEXPORT', 'DATABASEEXPORT', 'CREATE', false diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_enable_cbs_base_interest_rate_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_enable_cbs_base_interest_rate_maker_checker.xml new file mode 100644 index 000000000..c0e10637c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0470_enable_cbs_base_interest_rate_maker_checker.xml @@ -0,0 +1,68 @@ + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE', 'CBSBASEINTERESTRATE', 'CREATE', true + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_CBSBASEINTERESTRATE_CHECKER', 'CBSBASEINTERESTRATE', 'CREATE_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSBASEINTERESTRATE_CHECKER'); + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE entity_name = 'CBSBASEINTERESTRATE' + AND action_name = 'CREATE'; + + UPDATE m_permission + SET action_name = 'CREATE_CHECKER' + WHERE entity_name = 'CBSBASEINTERESTRATE' + AND code = 'CREATE_CBSBASEINTERESTRATE_CHECKER'; + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE LOWER(r.name) = 'clerk' + AND p.code IN ('CREATE_CBSBASEINTERESTRATE', 'CREATE_CBSBASEINTERESTRATE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_enable_charge_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_enable_charge_maker_checker.xml new file mode 100644 index 000000000..f0f7dd2db --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_enable_charge_maker_checker.xml @@ -0,0 +1,63 @@ + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE entity_name = 'CHARGE' + AND action_name = 'CREATE' + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE entity_name = 'CHARGE' + AND action_name = 'CREATE'; + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE LOWER(r.name) = 'clerk' + AND p.code IN ('CREATE_CHARGE', 'CREATE_CHARGE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_enable_product_creation_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_enable_product_creation_maker_checker.xml new file mode 100644 index 000000000..ed4387a11 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_enable_product_creation_maker_checker.xml @@ -0,0 +1,87 @@ + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER', 'SHAREPRODUCT', 'CREATE_CHECKER', FALSE + WHERE NOT EXISTS ( + SELECT 1 FROM m_permission WHERE code = 'CREATE_SHAREPRODUCT_CHECKER' + ); + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT') + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code IN ('CREATE_LOANPRODUCT', 'CREATE_SAVINGSPRODUCT', 'CREATE_CURRENTPRODUCT', 'CREATE_SHAREPRODUCT'); + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ( + 'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER', + 'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER', + 'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER', + 'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ( + 'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER', + 'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER', + 'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER', + 'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE LOWER(r.name) = 'clerk' + AND p.code IN ( + 'CREATE_LOANPRODUCT', 'CREATE_LOANPRODUCT_CHECKER', + 'CREATE_SAVINGSPRODUCT', 'CREATE_SAVINGSPRODUCT_CHECKER', + 'CREATE_CURRENTPRODUCT', 'CREATE_CURRENTPRODUCT_CHECKER', + 'CREATE_SHAREPRODUCT', 'CREATE_SHAREPRODUCT_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_enable_fixed_deposit_product_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_enable_fixed_deposit_product_maker_checker.xml new file mode 100644 index 000000000..6321c262c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_enable_fixed_deposit_product_maker_checker.xml @@ -0,0 +1,72 @@ + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER', 'FIXEDDEPOSITPRODUCT', 'CREATE_CHECKER', FALSE + WHERE NOT EXISTS ( + SELECT 1 FROM m_permission WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER' + ); + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT' + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code = 'CREATE_FIXEDDEPOSITPRODUCT'; + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE LOWER(r.name) = 'clerk' + AND p.code IN ('CREATE_FIXEDDEPOSITPRODUCT', 'CREATE_FIXEDDEPOSITPRODUCT_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index 31c90c9e9..2e34d1fe0 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -28,6 +28,7 @@ import org.apache.fineract.batch.exception.ErrorInfo; import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; import org.apache.fineract.infrastructure.codes.exception.CodeNotFoundException; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -113,4 +114,395 @@ public class CommandSourceServiceTest { Assertions.assertEquals(1001, result.getErrorCode()); Assertions.assertTrue(result.getMessage().contains("Code with name `foo` does not exist")); } + + @Test + public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, superUser, false); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(superUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } + + @Test + public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Create() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(100L); + Mockito.when(commandSource.getResourceId()).thenReturn(200L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Update_And_Disable() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("UPDATE"); + Mockito.when(commandSource.getId()).thenReturn(101L); + Mockito.when(commandSource.getResourceId()).thenReturn(201L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_GLAccount_SuperUser_EnforcesMakerChecker_Delete() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("DELETE_GLACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("DELETE"); + Mockito.when(commandSource.getId()).thenReturn(102L); + Mockito.when(commandSource.getResourceId()).thenReturn(202L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("DELETE_GLACCOUNT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_GLAccount_CheckerApproval_ExecutesSuccessfully() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("UPDATE_GLACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("UPDATE"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("UPDATE_GLACCOUNT")).thenReturn(true); + Mockito.when(checkerUser.isCheckerSuperUser()).thenReturn(false); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } + + @Test + public void testProcessCommand_GLAccount_NonSuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser regularUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_GLACCOUNT"); + Mockito.when(commandSource.getEntityName()).thenReturn("GLACCOUNT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(103L); + Mockito.when(commandSource.getResourceId()).thenReturn(203L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_GLACCOUNT")).thenReturn(true); + Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_CBSBaseInterestRate_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE"); + Mockito.when(commandSource.getEntityName()).thenReturn("CBSBASEINTERESTRATE"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(104L); + Mockito.when(commandSource.getResourceId()).thenReturn(204L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_CBSBaseInterestRate_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CBSBASEINTERESTRATE"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CBSBASEINTERESTRATE")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } + + @Test + public void testProcessCommand_Charge_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE"); + Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(105L); + Mockito.when(commandSource.getResourceId()).thenReturn(205L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_Charge_NonSuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser regularUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CHARGE"); + Mockito.when(commandSource.getEntityName()).thenReturn("CHARGE"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(106L); + Mockito.when(commandSource.getResourceId()).thenReturn(206L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CHARGE")).thenReturn(true); + Mockito.when(regularUser.isCheckerSuperUser()).thenReturn(false); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, regularUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_LoanProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("LOANPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(110L); + Mockito.when(commandSource.getResourceId()).thenReturn(210L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_SavingsProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SAVINGSPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(111L); + Mockito.when(commandSource.getResourceId()).thenReturn(211L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SAVINGSPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_CurrentProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_CURRENTPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("CURRENTPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(112L); + Mockito.when(commandSource.getResourceId()).thenReturn(212L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_CURRENTPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_ShareProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_SHAREPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("SHAREPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(113L); + Mockito.when(commandSource.getResourceId()).thenReturn(213L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_SHAREPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_Products_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_LOANPRODUCT"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_LOANPRODUCT")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/charge/handler/CreateChargeDefinitionCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/charge/handler/CreateChargeDefinitionCommandHandlerTest.java new file mode 100644 index 000000000..1f5b7bcde --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/charge/handler/CreateChargeDefinitionCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.charge.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.charge.service.ChargeWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateChargeDefinitionCommandHandlerTest { + + @Mock + private ChargeWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateChargeDefinitionCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(101L) + .withEntityId(201L) + .build(); + when(writePlatformService.createCharge(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).createCharge(command); + assertEquals(expectedResult, actualResult); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/handler/CreateLoanProductCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/handler/CreateLoanProductCommandHandlerTest.java new file mode 100644 index 000000000..c161a276f --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/handler/CreateLoanProductCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanproduct.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.loanproduct.service.LoanProductWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateLoanProductCommandHandlerTest { + + @Mock + private LoanProductWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateLoanProductCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(101L) + .withEntityId(201L) + .build(); + when(writePlatformService.createLoanProduct(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).createLoanProduct(command); + assertEquals(expectedResult, actualResult); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandlerTest.java new file mode 100644 index 000000000..742a06d06 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateCbsBaseInterestRateCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.savings.service.FdBaseInterestRateWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateCbsBaseInterestRateCommandHandlerTest { + + @Mock + private FdBaseInterestRateWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateCbsBaseInterestRateCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(100L) + .withEntityId(200L) + .build(); + when(writePlatformService.saveInterestRates(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).saveInterestRates(command); + assertEquals(expectedResult, actualResult); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateSavingsProductCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateSavingsProductCommandHandlerTest.java new file mode 100644 index 000000000..a5071b3cd --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/CreateSavingsProductCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.savings.service.SavingsProductWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateSavingsProductCommandHandlerTest { + + @Mock + private SavingsProductWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateSavingsProductCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(102L) + .withEntityId(202L) + .build(); + when(writePlatformService.create(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).create(command); + assertEquals(expectedResult, actualResult); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/current/CreateCurrentProductCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/current/CreateCurrentProductCommandHandlerTest.java new file mode 100644 index 000000000..ed9d72e80 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/handler/current/CreateCurrentProductCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.handler.current; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.currentproduct.service.CurrentProductWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateCurrentProductCommandHandlerTest { + + @Mock + private CurrentProductWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateCurrentProductCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(103L) + .withEntityId(203L) + .build(); + when(writePlatformService.create(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).create(command); + assertEquals(expectedResult, actualResult); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImplTest.java new file mode 100644 index 000000000..067ccdeb9 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateReadPlatformServiceImplTest.java @@ -0,0 +1,265 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest; +import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffective; +import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateEffectiveRepository; +import org.apache.fineract.portfolio.savings.domain.FdBaseInterestRateSlabHistoryRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FdBaseInterestRateReadPlatformServiceImplTest { + + @Mock + private FdBaseInterestRateEffectiveRepository effectiveRepository; + + @Mock + private FdBaseInterestRateSlabHistoryRepository slabRepository; + + @InjectMocks + private FdBaseInterestRateReadPlatformServiceImpl service; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.setTenant(null); + } + + private FdInterestRateSaveRequest.SlabRequest createSlab(int srNo, String desc, int periodType, int from, Integer to, double rate, double seniorRate) { + FdInterestRateSaveRequest.SlabRequest s = new FdInterestRateSaveRequest.SlabRequest(); + s.setSrNo(srNo); + s.setDescription(desc); + s.setPeriodTypeEnum(periodType); + s.setFromPeriod(from); + s.setToPeriod(to); + s.setAnnualInterestRate(BigDecimal.valueOf(rate)); + s.setSeniorCitizenRate(BigDecimal.valueOf(seniorRate)); + return s; + } + + @Test + void testValidSingleDepositSlabs() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + request.setVersionLabel("v1.0"); + + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0), + createSlab(2, "30 to 89 Days", 0, 30, 89, 5.0, 5.5), + createSlab(3, "90 to 179 Days", 0, 90, 179, 5.5, 6.0), + createSlab(4, "180+ Days", 0, 180, null, 6.0, 6.5) + ); + request.setSlabs(slabs); + + when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("SINGLE")) + .thenReturn(Collections.emptyList()); + when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertDoesNotThrow(() -> service.saveInterestRates(request, 1L)); + verify(effectiveRepository).saveAndFlush(any()); + } + + @Test + void testValidDoubleDepositSlabs() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("DOUBLE"); + request.setVersionLabel("v1.0"); + + List slabs = List.of( + createSlab(1, "1 to 2 Years", 3, 1, 2, 6.5, 7.0), + createSlab(2, "3 to 5 Years", 3, 3, 5, 6.75, 7.25), + createSlab(3, "6 to 10 Years", 3, 6, 10, 7.0, 7.5), + createSlab(4, "11+ Years", 3, 11, null, 7.25, 7.75) + ); + request.setSlabs(slabs); + + when(effectiveRepository.findByFdTypeAndIsActiveTrueOrderByEffectiveFromDateDesc("DOUBLE")) + .thenReturn(Collections.emptyList()); + when(effectiveRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertDoesNotThrow(() -> service.saveInterestRates(request, 1L)); + } + + @Test + void testInvalidFirstSlabDoesNotStartAtOne() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "2 to 29 Days", 0, 2, 29, 4.5, 5.0), + createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals(1, ex.getErrors().size()); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].fromPeriod.first.slab.must.start.at.one", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidOverlapRanges() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0), + createSlab(2, "20 to 50 Days", 0, 20, 50, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidGapBetweenRanges() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0), + createSlab(2, "35 to 89 Days", 0, 35, 89, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.be.continuous.with.previous.range", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidDuplicateRanges() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0), + createSlab(2, "1 to 29 Days duplicate", 0, 1, 29, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].fromPeriod.must.not.overlap.previous.range", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidFromGreaterThanTo() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 20 Days", 0, 1, 20, 4.5, 5.0), + createSlab(2, "21 to 10 Days", 0, 21, 10, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].toPeriod.cannot.be.less.than.fromPeriod", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidBlankToPeriodOnIntermediateSlab() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to null Days", 0, 1, null, 4.5, 5.0), + createSlab(2, "30+ Days", 0, 30, null, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].toPeriod.cannot.be.blank.for.intermediate.slab", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidMixedPeriodTypes() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, 5.0), + createSlab(2, "30 to 89 Months", 2, 30, 89, 5.0, 5.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[2].periodTypeEnum.must.be.consistent.across.all.slabs", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidNegativeInterestRate() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, -1.0, 5.0) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].annualInterestRate.must.be.non.negative", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } + + @Test + void testInvalidNegativeSeniorRate() { + FdInterestRateSaveRequest request = new FdInterestRateSaveRequest(); + request.setFdType("SINGLE"); + List slabs = List.of( + createSlab(1, "1 to 29 Days", 0, 1, 29, 4.5, -0.5) + ); + request.setSlabs(slabs); + + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> service.saveInterestRates(request, 1L)); + assertEquals("validation.msg.FD_BASE_INTEREST_RATE_VERSION.slabs[1].seniorCitizenRate.must.be.non.negative", + ex.getErrors().get(0).getUserMessageGlobalisationCode()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImplTest.java new file mode 100644 index 000000000..3da1c04de --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/savings/service/FdBaseInterestRateWritePlatformServiceImplTest.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest; +import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FdBaseInterestRateWritePlatformServiceImplTest { + + @Mock + private PlatformSecurityContext context; + + @Mock + private FdBaseInterestRateReadPlatformService readPlatformService; + + @Mock + private JsonCommand command; + + @Mock + private AppUser appUser; + + @InjectMocks + private FdBaseInterestRateWritePlatformServiceImpl writePlatformService; + + @Test + void shouldParseJsonAndSaveInterestRates() { + String jsonPayload = "{\"fdType\":\"SINGLE\",\"versionLabel\":\"v1.0\",\"slabs\":[{\"srNo\":1,\"description\":\"1 to 29 Days\",\"periodTypeEnum\":0,\"fromPeriod\":1,\"toPeriod\":29,\"annualInterestRate\":5.0,\"seniorCitizenRate\":5.5}]}"; + when(command.json()).thenReturn(jsonPayload); + when(command.commandId()).thenReturn(789L); + when(context.authenticatedUser()).thenReturn(appUser); + when(appUser.getId()).thenReturn(1L); + + FdInterestRateEffectiveData savedData = FdInterestRateEffectiveData.builder() + .id(10L) + .fdType("SINGLE") + .versionLabel("v1.0") + .build(); + when(readPlatformService.saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L))).thenReturn(savedData); + + CommandProcessingResult result = writePlatformService.saveInterestRates(command); + + assertNotNull(result); + assertEquals(789L, result.getCommandId()); + assertEquals(10L, result.getResourceId()); + verify(readPlatformService).saveInterestRates(any(FdInterestRateSaveRequest.class), eq(1L)); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/shareproducts/handler/CreateShareProductCommandHandlerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/shareproducts/handler/CreateShareProductCommandHandlerTest.java new file mode 100644 index 000000000..ba8154b6d --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/shareproducts/handler/CreateShareProductCommandHandlerTest.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.portfolio.shareproducts.service.ShareProductWritePlatformService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreateShareProductCommandHandlerTest { + + @Mock + private ShareProductWritePlatformService writePlatformService; + + @Mock + private JsonCommand command; + + @InjectMocks + private CreateShareProductCommandHandler underTest; + + @Test + void shouldDelegateToWritePlatformServiceAndReturnResult() { + CommandProcessingResult expectedResult = new CommandProcessingResultBuilder() + .withCommandId(104L) + .withEntityId(204L) + .build(); + when(writePlatformService.createShareProduct(command)).thenReturn(expectedResult); + + CommandProcessingResult actualResult = underTest.processCommand(command); + + verify(writePlatformService).createShareProduct(command); + assertEquals(expectedResult, actualResult); + } +} From a42a874811d087ac503df9afc32f85efe9cceb18 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 26 Aug 2026 18:49:52 +0530 Subject: [PATCH 10/55] fix(deposits): stop FD Special interest postings from self-correcting after settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FD Special (monthly interest payout) accounts post interest each month and immediately sweep it to a linked savings account. Two independent interest recalculation paths were treating that sweep withdrawal as ordinary account activity and silently reversing + reposting already-settled periods at a slightly different (rounding-drifted) amount — after the original, correct amount had already been transferred out. This left the FD short by the difference every time it happened. Root causes (two separate engines, same underlying bug pattern): 1. SavingsAccountDomainServiceJpa#handleWithdrawal re-triggers a full postInterest()/calculateInterestUsing() after every withdrawal, including the sweep withdrawal itself. At that exact moment the withdrawal's own m_account_transfer_transaction linking row does not exist yet, so the balance-exclusion logic can't recognise it as a transfer leg — it gets treated as a real balance-reducing transaction and corrupts that period's (and any later period's) interest calculation. 2. SavingsAccountInterestPostingServiceImpl (the "Post Interest For Savings" batch job) has its own, separate reverse-and-repost correction loop with the same lack of a "was this period already settled" guard. Fixes, both scoped strictly to FD Special (transfer_interest_to_linked_account = true) so plain savings and recurring deposit behaviour is provably unchanged (the new hooks all default to the prior behaviour unless overridden): - SavingsAccount: new isInterestPostingCorrectionAllowed(...) extension point guarding the postInterest() correction loop; new isPostWithdrawalInterestRecalculationRequired(...) hook checked by handleWithdrawal before it re-recalculates; new refreshBalancesAndSummaryAfterTransfer(...) — the running-balance/summary bookkeeping postInterest() always performs, without re-running the posting/correction loop, so skipping the full recalculation doesn't leave the withdrawal's own running balance and the account summary stale. - FixedDepositAccount: overrides both hooks using the existing interestAlreadyPaidOut(...) date-matched sweep-withdrawal check (already used by postMaturityInterest) so an already-swept period is never recalculated again, from either the withdrawal path or a later job run. - SavingsAccountInterestPostingServiceImpl: mirrors the same guard (isInterestAlreadySweptOut) in its own correction loop for the batch job. Unrelated correctness fixes carried in the same files, applicable to any account type (not FD-Special-specific), found while investigating the above: - SavingsAccountWritePlatformServiceJpaRepositoryImpl: interest-payable GL entries were reading a financial_account_type that never exists in acc_product_mapping, always resolving to account id 0. Now reads the correct getGlAccountIdForInterestOnSavings(). - DepositAccountInterestRateChart#getApplicableInterestRate: missing break in the slab-matching loop meant HashSet's non-deterministic iteration order could pick an arbitrary matching slab instead of the intended one. - SavingsAccountReadPlatformServiceImpl / SavingsAccountData: thread the real transfer_interest_to_linked_account flag into the batch interest engine's per-account data so it can correctly disable interest compounding for FD Special (previously hard-coded false, causing the batch job to compound interest that should reset to zero each period). Co-Authored-By: Claude Sonnet 5 --- .../savings/data/SavingsAccountData.java | 15 ++ .../savings/domain/FixedDepositAccount.java | 182 +++++++++++++++++- .../SavingsAccountDomainServiceJpa.java | 16 +- ...ingsAccountInterestPostingServiceImpl.java | 37 +++- ...SavingsAccountReadPlatformServiceImpl.java | 14 +- ...WritePlatformServiceJpaRepositoryImpl.java | 2 +- .../DepositAccountInterestRateChart.java | 3 + .../savings/domain/SavingsAccount.java | 99 +++++++++- 8 files changed, 352 insertions(+), 16 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index def3e27f7..bafe4a1c7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -217,6 +217,21 @@ public final class SavingsAccountData implements Serializable { private BigDecimal interestPosting; private BigDecimal overdraftPosting; + /** + * True when this deposit pays its interest out to a linked savings account every period (FD Special). + * Such interest never stays in the account, so it must never compound -- every period earns on the + * principal alone. Transient because it is only read by the batch interest-posting engine. + */ + private transient boolean transferInterestToLinkedAccount; + + public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) { + this.transferInterestToLinkedAccount = transferInterestToLinkedAccount; + } + + public boolean isTransferInterestToLinkedAccount() { + return this.transferInterestToLinkedAccount; + } + public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate, BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum, EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 47b02dcf0..d1bbdc50c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -150,6 +150,18 @@ public class FixedDepositAccount extends SavingsAccount { } } + /** + * Re-forces the standing instructions that make FD Special work -- see + * {@link DepositAccountTermAndPreClosure#forceMonthlyPayoutToLinkedSavings}. Called from the modify-application + * write path after this same request's own linked-account resolution has settled, since + * {@link #modifyApplication} (via {@code accountTermAndPreClosure.update}) has no fdType awareness and would + * otherwise let a SPECIAL account's transferInterestToLinkedAccount/onAccountClosureType/transferToSavingsId + * be silently unset by that same request. + */ + public void forceSpecialFdMonthlyPayoutSettings(final Long transferToSavingsId) { + this.accountTermAndPreClosure.forceMonthlyPayoutToLinkedSavings(transferToSavingsId); + } + @Override protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) { boolean isPreMatureClosure = false; @@ -250,7 +262,13 @@ public class FixedDepositAccount extends SavingsAccount { totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned()); } final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount()); - final Money maturityAmount = depositAmount.plus(totalInterestPayable); + // FD Special pays each period's interest out to the linked savings account every month instead of + // compounding it into this account's own balance (see DepositAccountAssembler and + // TransferInterestToSavingsTasklet) -- so unlike a cumulative FD, this account's own balance never + // actually grows to depositAmount + totalInterestPayable by maturity; it stays at depositAmount + // throughout the term. Showing the compounded projection here would be a customer-facing figure + // that never happens. SINGLE/DOUBLE FDs are unaffected -- they still get the real compounded value. + final Money maturityAmount = "SPECIAL".equalsIgnoreCase(fdType()) ? depositAmount : depositAmount.plus(totalInterestPayable); this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate); } @@ -358,6 +376,91 @@ public class FixedDepositAccount extends SavingsAccount { return allPostingPeriods; } + /** + * Read-only period-by-period (e.g. month-wise, when posting is Monthly) projected interest schedule across + * the account's current term -- built with the exact same {@link PostingPeriod} engine as + * {@link #calculateInterestPayable}, but deliberately NOT sharing that method's body: that one also calls + * {@code this.summary.updateFromInterestPeriodSummaries(...)}, which overwrites the account's real + * totalInterestEarned/lastInterestCalculationDate -- fine when it runs once as part of an actual save, but + * corrupting if repeated on every GET request. This variant skips that call entirely, so it is safe to + * invoke as many times as a read-service likes without ever mutating the account. Used to show FD Special + * customers/staff exactly how much interest is projected to post each month across the whole term, since + * that figure otherwise only exists internally as a discarded intermediate value. + */ + public List projectInterestSchedule(final MathContext mc, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + List transactions = retreiveOrderedNonInterestPostingTransactions(); + if (transactions.isEmpty()) { + // Account activated but not yet funded (funding is a separate deposit step) -- project the + // schedule as if the planned deposit amount lands on day one, the same synthetic-transaction + // approach updateMaturityDateAndAmountBeforeAccountActivation uses for the pre-activation + // maturity preview. Without this every period here would show zero interest against a real, + // still-empty transaction history, while the maturity amount (forced to depositAmount for + // SPECIAL FDs) already assumes the account gets funded -- this keeps the two consistent. + final Money transactionAmountMoney = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount()); + final SavingsAccountTransaction syntheticDeposit = SavingsAccountTransaction.deposit(null, office(), null, + this.accountSubmittedOrActivationDate(), transactionAmountMoney, null); + syntheticDeposit.setRunningBalance(transactionAmountMoney); + syntheticDeposit.updateCumulativeBalanceAndDates(this.getCurrency(), interestCalculatedUpto()); + transactions = new ArrayList<>(); + transactions.add(syntheticDeposit); + } + final LocalDate maturityDate = maturityDate() != null ? maturityDate() : calculateMaturityDate(); + // calculateInterestPayable's own "maturityDate" parameter is always fed maturityDate.minusDays(1) + // by its one real caller (see updateMaturityDateAndAmount above) -- the raw maturity date is only + // used for the end-of-day-balance reset either side of the calculation. Missing this -1 here made + // determineInterestPostingPeriods run one day too far, pushing the final period's end date (and + // therefore its amount) past the account's actual maturity date. + final LocalDate interestCalculationUpto = maturityDate.minusDays(1); + final boolean isPreMatureClosure = false; + + this.resetAccountTransactionsEndOfDayBalances(transactions, maturityDate); + try { + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType + .fromInt(this.interestPostingPeriodType); + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + final List postedAsOnTransactionDates = getManualPostingDates(); + + final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( + depositStartDate(), interestCalculationUpto, postingPeriodType, financialYearBeginningMonth, + postedAsOnTransactionDates); + + final List allPostingPeriods = new ArrayList<>(); + Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount()) + : Money.zero(currency); + + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType + .fromInt(this.interestCalculationType); + final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, interestCalculationUpto, isPreMatureClosure); + final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); + final boolean isInterestTransfer = false; + final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); + final List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( + transactions); + + for (final LocalDateInterval periodInterval : postingPeriodIntervals) { + final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate()); + final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, + savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType, + interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), interestCalculationUpto, + interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, + isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, financialYearBeginningMonth); + + periodStartingBalance = postingPeriod.closingBalance(); + allPostingPeriods.add(postingPeriod); + } + + this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), + isTransferInterestToOtherAccount()); + return allPostingPeriods; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); + } + } + public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) @@ -573,7 +676,13 @@ public class FixedDepositAccount extends SavingsAccount { interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); this.transactions.add(newPostingTransaction); recalucateDailyBalanceDetails = true; - } else { + } else if (!interestAlreadyPaidOut(postingTransaction)) { + // Once a period's interest has actually been swept out to the linked savings account it is + // settled history: reversing and re-posting it here would rewrite interest the customer was + // already paid and leave this account's books disagreeing with the cash that left it. But a + // posting still sitting IN the account is fair game -- that is how a stale amount (e.g. one + // the batch engine compounded) gets corrected at maturity. Testing the individual period, + // not the account-wide transfer flag, is the distinction that matters here. final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); if (correctionRequired) { postingTransaction.reverse(); @@ -595,6 +704,57 @@ public class FixedDepositAccount extends SavingsAccount { this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); } + /** + * Whether this period's posted interest has already left the account for the linked savings account. + *

+ * The sweep withdrawal carries the same transaction date as the interest posting it pays out (see + * DepositAccountReadPlatformService#retrieveDataForInterestTransfer, which pairs them by date), and + * {@link #interestTransferWithdrawalTransactionIds()} returns those WITHDRAWAL ids -- not the posting + * ids -- so the match has to be on date, not on id. + */ + private boolean interestAlreadyPaidOut(final SavingsAccountTransaction postingTransaction) { + final Collection sweepWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (sweepWithdrawalIds.isEmpty()) { + return false; + } + for (final SavingsAccountTransaction transaction : this.transactions) { + if (transaction.isNotReversed() && sweepWithdrawalIds.contains(transaction.getId()) + && DateUtils.isEqual(transaction.getTransactionDate(), postingTransaction.getTransactionDate())) { + return true; + } + } + return false; + } + + /** + * Guards the base {@link SavingsAccount#postInterest} correction loop, which base + * {@code SavingsAccountDomainServiceJpa#handleWithdrawal} re-triggers after every withdrawal -- including the + * sweep withdrawal that transfers a period's interest out to the linked savings account. Without this guard, + * that recalculation reverses and reposts the just-paid-out period at a freshly-recomputed (and slightly + * different, e.g. rounding) amount, leaving the already-executed sweep withdrawal mismatched against the new + * posting. + */ + @Override + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return !interestAlreadyPaidOut(postingTransaction); + } + + /** + * The interest-transfer sweep withdrawal only ever fires for a period whose interest is already posted (see + * DepositAccountReadPlatformService#retrieveDataForInterestTransfer), so there is nothing new for this + * recalculation to find. Worse, at the moment this withdrawal's own post-withdrawal recalculation would run, + * the m_account_transfer_transaction linking row that marks it as a transfer withdrawal does not exist yet + * (it is created by AccountTransfersWritePlatformServiceImpl only after both transfer legs are booked) -- so + * the exclusion in {@link #resetAccountTransactionsEndOfDayBalances} cannot yet recognise it, and the + * recalculation would wrongly treat it as a real balance-reducing transaction for its own and every later + * period, reversing and reposting already-settled interest at a corrupted amount. Skipping the recalculation + * for this specific withdrawal avoids the corruption at its source. + */ + @Override + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return !isInterestTransfer; + } + public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { @@ -809,6 +969,24 @@ public class FixedDepositAccount extends SavingsAccount { return uptoMaturityDate; } + /** + * An FD that pays its interest out monthly (FD Special) carries a transfer-out WITHDRAWAL for every posted + * period, and {@link #retreiveOrderedNonInterestPostingTransactions()} strips those out before the + * interest engine sees them. The balance-segment walk has to strip the same ones: that withdrawal is dated + * the same day as the posting it pays out, so leaving it in ends the funding deposit's balance segment one + * day early while the engine's own list has nothing to cover that day -- costing the first period one + * earning day (e.g. 6 days instead of 25-Jul..31-Jul's real 7). + *

+ * A no-op for every other account: the excluded-id lookup short-circuits to empty unless + * {@link #isTransferInterestToOtherAccount()} is true. + */ + @Override + protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, + final LocalDate interestPostingUpToDate) { + super.resetAccountTransactionsEndOfDayBalances(excludeInterestTransferWithdrawals(accountTransactionsSorted), + interestPostingUpToDate); + } + public void validateDomainRules() { final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 80b9c1a0d..45c852a78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -114,13 +114,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi final LocalDate today = DateUtils.getBusinessLocalDate(); - if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) { - account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals); + if (account.isPostWithdrawalInterestRecalculationRequired(transactionBooleanValues.isInterestTransfer())) { + if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) { + account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals); + } else { + account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(), + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, + backdatedTxnsAllowedTill, postReversals); + } } else { - account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(), - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, - postReversals); + account.refreshBalancesAndSummaryAfterTransfer(today, backdatedTxnsAllowedTill, postReversals); } List depositAccountOnHoldTransactions = null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index f315eb3c2..4b255ddf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -119,7 +119,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } else { correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); } - if (correctionRequired) { + if (correctionRequired && !isInterestAlreadySweptOut(interestPostingTransactionDate, savingsAccountData)) { boolean applyWithHoldTaxForOldTransaction = false; postingTransaction.reverse(); @@ -185,6 +185,28 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return savingsAccountData; } + /** + * Mirrors {@code FixedDepositAccount#interestAlreadyPaidOut}: for an FD Special account whose interest sweeps + * out to a linked savings account, a withdrawal dated the same day as an interest posting means that period's + * interest has already left the account (see DepositAccountReadPlatformService#retrieveDataForInterestTransfer, + * which pairs them by date). Recalculating and reposting it here -- as this batch job otherwise would for + * every account, FD or not -- would silently disagree with money that has already moved. Scoped to FD Special + * only, so plain savings and recurring deposit batch posting is unaffected. + */ + private boolean isInterestAlreadySweptOut(final LocalDate interestPostingTransactionDate, final SavingsAccountData savingsAccountData) { + if (!savingsAccountData.isTransferInterestToLinkedAccount() + || !DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId())) { + return false; + } + for (final SavingsAccountTransactionData transaction : savingsAccountData.getSavingsAccountTransactionData()) { + if (transaction.isWithdrawal() && transaction.isNotReversed() + && DateUtils.isEqual(transaction.getTransactionDate(), interestPostingTransactionDate)) { + return true; + } + } + return false; + } + protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate, final List transactions) { SavingsAccountTransactionData transaction = null; @@ -341,8 +363,19 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } } + // A deposit that sweeps its interest out to a linked savings account every period (FD Special) + // must never compound: the interest does not stay in the account, so each period earns on the + // principal alone. This used to be hard-coded false here, which made this batch engine disagree + // with the domain engine (SavingsAccount#calculateInterestUsing, which passes the real flag) -- + // the same account could be posted a compounded amount by the nightly job and a simple amount by + // its own maturity run, minutes apart. Deliberately scoped to fixed deposits so recurring + // deposits keep their existing behaviour. + // getDepositTypeId() rather than depositAccountType(), which dereferences the enum unguarded -- + // this runs per account inside a batch job, so a null deposit type must not take the whole run down. + final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount() + && DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId()); this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods, - getLockedInUntilLocalDate(savingsAccountData), false); + getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled); savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index ce7ce86fc..6ade6bfff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -349,6 +349,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead final StringBuilder sqlBuilder = new StringBuilder(400); sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); sqlBuilder.append("sa.status_enum as statusEnum, "); @@ -446,6 +447,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); + // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a + // cumulative one: interest that is swept out to a linked account each month must never + // compound, and without this flag the engine compounded it. Left join because ordinary + // savings accounts have no term-and-preclosure row at all. + sqlBuilder.append( + "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); this.schemaSql = sqlBuilder.toString(); } @@ -668,6 +675,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); + // Monthly-payout deposits must not compound: their interest leaves the account every + // period, so each period earns on the principal alone. + savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); } if (!transMap.containsValue(transactionId)) { @@ -1142,8 +1152,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, new com.google.gson.reflect.TypeToken>() {}.getType()); accountData.setNomineeData(nomineeMap); - } catch (Exception ignored) { - // intentionally ignored + } catch (Exception ignored) { + // intentionally ignored } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index aad5ddcf4..10573774a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -643,7 +643,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable()); } } else { - accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable()); + accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestOnSavings()); accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl()); } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java index e2007c80e..1b2d39ca3 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java @@ -151,6 +151,9 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom return identifiers; } + /** + * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto + * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the + * correction, so plain savings and recurring deposit behaviour is unchanged. + */ + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return true; + } + + /** + * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an + * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation + * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit + * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called + * instead. + */ + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return true; + } + + /** + * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest + * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running + * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, + * regardless of whether any posting correction was needed -- without re-running the interest + * posting/correction loop that recalculation drives. + */ + public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, + final boolean postReversals) { + Money openingAccountBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { @@ -581,7 +627,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } else { correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); } - if (correctionRequired) { + if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { boolean applyWithHoldTaxForOldTransaction = false; postingTransaction.reverse(); SavingsAccountTransaction reversal = null; @@ -1047,14 +1093,59 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); } + /** + * Transactions that should NOT count as part of this account's own balance history for interest + * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an + * input to it) AND, for accounts that transfer their interest out to a linked account + * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later + * sweeps that same interest back out (see TransferInterestToSavingsTasklet). + *

+ * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here + * -- the debit half stays, permanently understating this account's balance (and therefore every + * subsequent period's interest) by that amount from the first transfer onward, even though the real + * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the + * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this + * account, so excluding those ids here closes that gap. + */ + protected Collection interestTransferWithdrawalTransactionIds() { + if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(getId()); + } + + /** + * The given transactions minus the interest-transfer withdrawal legs, exactly as + * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever + * sees them. + *

+ * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's + * balance segment by walking backwards and ending the previous segment one day before the next + * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a + * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose + * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently + * cost the very first period one earning day. + */ + protected List excludeInterestTransferWithdrawals( + final List transactions) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + protected List retreiveOrderedNonInterestPostingTransactions() { final List listOfTransactionsSorted = retrieveListOfTransactions(); final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } } @@ -1066,10 +1157,12 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom final List listOfTransactionsSorted = retrieveSortedTransactions(); final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } } From 72221f704d0e7de109049e47150d17354f1dcdc6 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 26 Aug 2026 19:02:17 +0530 Subject: [PATCH 11/55] feat(deposits,clients): FD Special auto-configuration, interest schedule projection, and minor-age backend mirror Three related pieces of backend support for this session's FD Special (monthly interest payout) work, plus one more instance of the same transaction-ordering bug fixed elsewhere this session. 1. FD Special account creation/modification auto-configuration FD Special (fdType=SPECIAL) is a non-cumulative product: interest must post monthly and leave for a linked savings account, and maturity must auto-transfer the principal out -- regardless of whatever compounding/ posting period or maturity instruction the request otherwise carries. Previously none of this was enforced, so a SPECIAL account could be created with e.g. quarterly compounding, or a later "modify application" request could silently unset transferInterestToLinkedAccount / the maturity instruction / the linked account by mere omission of those fields from the payload, while the account kept displaying as "FD Special". - DepositAccountAssembler: on create, forces interestCompoundingPeriodType and interestPostingPeriodType to MONTHLY, transferInterestToSavings to true, transferToSavingsId to the (now required) linked savings account, and accountOnClosureType to TRANSFER_TO_SAVINGS, whenever fdType is SPECIAL. Throws a clear domain error if no linked account was selected. - DepositAccountTermAndPreClosure: new forceMonthlyPayoutToLinkedSavings(), re-applying those same three standing-instruction fields. - DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl: calls the equivalent re-enforcement on every modify-application request for a SPECIAL account, after the linked-account resolution in that same method has settled what account (if any) ends up linked. 2. FD Special projected interest schedule (engine + API) Staff/customers previously only saw a flat rate/12 approximation of monthly interest. Added a real, engine-computed month-by-month schedule: - DepositAccountWritePlatformService(Impl): new retrieveProjectedInterestSchedule(), running the same PostingPeriod engine the real maturity calculation uses (FixedDepositAccount# projectInterestSchedule), side-effect-free (@Transactional(readOnly)), clipping the last period's displayed end-date to maturityDate-1 (the engine otherwise rounds it out to the next calendar boundary even when maturity falls partway through), and flagging each period "paid" by matching real posted-interest transaction dates. - FixedDepositAccountsApiResource: computes and attaches the schedule, plus server-computed totalProjectedInterest/totalValueAtMaturity, to the FD template response for SPECIAL accounts only. - FixedDepositAccountData / DepositsApiConstants: carrier fields and response-parameter whitelist entries for the three new fields. 3. Minor-age threshold: backend now matches the frontend's shared constant Companion to this session's frontend minor-age-threshold change (18 -> 10, see client-age.util.ts). ClientKycApiResource's minor detection (which gates the required-KYC-document list, including whether PAN is mandatory) was still hardcoded to age < 18. Added ClientApiConstants.MINOR_AGE_THRESHOLD (10) and SENIOR_CITIZEN_AGE_ THRESHOLD (60) as the backend's own named constants, and switched the KYC endpoint to use MINOR_AGE_THRESHOLD, so the two sides can't silently disagree on who counts as a minor. 4. SavingsAccountTransactionComparator: same-date tie-break fixes another instance of this session's core reversal bug For a Special FD's interest-transfer sweep, the withdrawal shares the exact transaction_date of the interest posting it pays out. When that posting is reversed and recreated, the new posting transaction gets a higher id than the already-existing withdrawal; the comparator's id-order tiebreaker then sorted the withdrawal before the new posting, which understated the running balance shown between them (e.g. showing Rs.99,901.39 instead of Rs.100,000.00 immediately after the pair). Added an explicit tiebreaker so a same-date, same-createdDate interest posting always sorts before a withdrawal/debit. Co-Authored-By: Claude Sonnet 5 --- .../client/api/ClientApiConstants.java | 9 +++ .../savings/DepositsApiConstants.java | 4 ++ .../client/api/ClientKycApiResource.java | 9 ++- .../api/FixedDepositAccountsApiResource.java | 24 +++++++ .../domain/DepositAccountAssembler.java | 49 ++++++++++++-- ...WritePlatformServiceJpaRepositoryImpl.java | 67 +++++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 25 +++++++ .../savings/data/FixedDepositAccountData.java | 25 +++++++ .../DepositAccountTermAndPreClosure.java | 13 ++++ .../SavingsAccountTransactionComparator.java | 16 +++++ .../DepositAccountWritePlatformService.java | 4 ++ 11 files changed, 236 insertions(+), 9 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index 5f5e99096..daf7c1383 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -162,6 +162,15 @@ public class ClientApiConstants { public static final String ageDaysParamName = "ageDays"; public static final String nationalityIdParamName = "nationalityId"; public static final String isMinorParamName = "isMinor"; + /** + * Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD + * document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the + * KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult + * list (with mandatory PAN) for a client the UI classifies as a minor. + */ + public static final int MINOR_AGE_THRESHOLD = 10; + /** At or above this age a client qualifies for senior-citizen deposit rates. */ + public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60; public static final String guardianClientIdParamName = "guardianClientId"; public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName"; public static final String guardianRelationParamName = "guardianRelation"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index f3e7eba4f..240914b13 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -589,6 +589,10 @@ public final class DepositsApiConstants { fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName); fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName); fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName); + // FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne) + fixedDepositResponseData.add("projectedInterestSchedule"); + fixedDepositResponseData.add("totalProjectedInterest"); + fixedDepositResponseData.add("totalValueAtMaturity"); return fixedDepositResponseData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java index ebc4e49a3..9391a8aac 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java @@ -160,10 +160,13 @@ public class ClientKycApiResource { template.put("clientTypeName", clientTypeName); template.put("legalFormId", legalFormId); - // Minor detection mirrors the frontend's own rule (age < 18) so the document list - // served here stays consistent with what the UI computes from the same date of birth. + // Minor detection mirrors the frontend's own rule so the document list served here stays + // consistent with what the UI computes from the same date of birth. Both sides read the + // threshold from a single named constant -- when they disagree, this endpoint wins and would + // silently serve an adult document list (mandatory PAN) to a minor. final LocalDate dateOfBirth = client.dateOfBirth(); - final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18; + final boolean isMinor = dateOfBirth != null + && Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD; template.put("isMinor", isMinor); template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index 8d6e0c241..d0b4aa3f5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -81,6 +81,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService; import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; @@ -107,6 +108,7 @@ public class FixedDepositAccountsApiResource { private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService; private final SqlValidator sqlValidator; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; @GET @Path("template") @@ -245,6 +247,28 @@ public class FixedDepositAccountsApiResource { final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly, chargeStatus, uriInfo, mandatoryResponseParameters); accountTemplate.setActivationCharge(getActivationCharge(accountId)); + // FD Special customers/staff need to see exactly how much interest is projected each month across + // the whole term, not just a flat rate/12 approximation -- computed on demand from the real + // interest engine (FixedDepositAccount#projectInterestSchedule), side-effect-free, so this GET can + // safely recompute it on every request. SINGLE/DOUBLE FDs never get this field. + if ("SPECIAL".equalsIgnoreCase(account.getFdType())) { + final java.util.List> schedule = this.depositAccountWritePlatformService + .retrieveProjectedInterestSchedule(accountId, DepositAccountType.FIXED_DEPOSIT); + accountTemplate.setProjectedInterestSchedule(schedule); + // Computed once here -- the single source of truth for "what does the customer end up with + // overall" -- rather than the frontend re-summing the schedule array itself, the same way + // maturityAmount itself is always a backend-computed field, never a client-side derivation. + BigDecimal totalInterest = BigDecimal.ZERO; + for (final java.util.Map period : schedule) { + final Object amount = period.get("interestAmount"); + if (amount instanceof BigDecimal) { + totalInterest = totalInterest.add((BigDecimal) amount); + } + } + final BigDecimal principal = accountTemplate.getDepositAmount(); + final BigDecimal totalValue = principal != null ? principal.add(totalInterest) : totalInterest; + accountTemplate.setProjectedInterestTotals(totalInterest, totalValue); + } final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(), mandatoryResponseParameters); return this.toApiJsonSerializer.serialize(settings, accountTemplate, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java index 7fa657aa5..a0deb4eab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java @@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expecte import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.linkedAccountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName; @@ -68,6 +69,7 @@ import java.util.Locale; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; @@ -248,6 +250,14 @@ public class DepositAccountAssembler { } } + // FD Special (fdType=SPECIAL) is a non-cumulative, monthly-interest-payout product: interest must + // post every month and leave the account for its linked savings account (see + // assembleAccountTermAndPreClosure below and the maturity-instruction override there), regardless + // of whatever compounding/posting period the product or the request otherwise specifies. SINGLE and + // DOUBLE FD types are untouched by this flag. + final boolean isSpecialFixedDeposit = depositAccountType.isFixedDeposit() && product instanceof FixedDepositProduct + && "SPECIAL".equalsIgnoreCase(((FixedDepositProduct) product).getFdType()); + SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null; final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName); if (interestPeriodTypeValue != null) { @@ -255,6 +265,13 @@ public class DepositAccountAssembler { } else { interestCompoundingPeriodType = product.interestCompoundingPeriodType(); } + if (isSpecialFixedDeposit) { + // Keep compounding no coarser than the forced Monthly posting below -- compounded interest gets + // reset to zero every period anyway once transferInterestToLinkedAccount is true (see + // CompoundInterestHelper), so this is only about keeping the interest engine's own + // posting/compounding-period expectations internally consistent, not a behaviour choice. + interestCompoundingPeriodType = SavingsCompoundingInterestPeriodType.MONTHLY; + } SavingsPostingInterestPeriodType interestPostingPeriodType = null; final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName); @@ -263,6 +280,9 @@ public class DepositAccountAssembler { } else { interestPostingPeriodType = product.interestPostingPeriodType(); } + if (isSpecialFixedDeposit) { + interestPostingPeriodType = SavingsPostingInterestPeriodType.MONTHLY; + } SavingsInterestCalculationType interestCalculationType = null; final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName); @@ -366,7 +386,7 @@ public class DepositAccountAssembler { if (depositAccountType.isFixedDeposit()) { final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure(); final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command, - prodTermAndPreClosure); + prodTermAndPreClosure, isSpecialFixedDeposit); FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer, accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate, @@ -380,7 +400,7 @@ public class DepositAccountAssembler { final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product) .depositProductTermAndPreClosure(); final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command, - prodTermAndPreClosure); + prodTermAndPreClosure, false); final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail(); final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command, @@ -420,7 +440,7 @@ public class DepositAccountAssembler { } public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command, - final DepositProductTermAndPreClosure productTermAndPreclosure) { + final DepositProductTermAndPreClosure productTermAndPreclosure, final boolean forceMonthlyPayoutToLinkedSavings) { final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositPreClosureDetail(); final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail(); @@ -435,17 +455,34 @@ public class DepositAccountAssembler { final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId); final SavingsAccount account = null; final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName); - final Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName); + Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName); // calculate maturity amount final BigDecimal maturityAmount = null;// calculated and updated in // account final LocalDate maturityDate = null;// calculated and updated in account final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName); - final DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null + DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null ? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId) : null; - final Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName); + Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName); + + if (forceMonthlyPayoutToLinkedSavings) { + final Long linkedSavingsAccountId = command.longValueOfParameterNamed(linkedAccountParamName); + if (linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.special.requires.linked.savings.account", + "FD Special (Monthly Interest Payout) requires a linked savings account to be selected."); + } + // Non-cumulative payout FD: every month's interest leaves for the linked savings account + // (TransferInterestToSavingsTasklet) instead of compounding, and maturity re-uses the existing + // "Transfer to Savings" maturity-instruction path (see + // DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails) to move the + // principal out and close the account automatically, with no staff action needed. + trasferInterest = true; + transferToSavingsId = linkedSavingsAccountId; + accountOnClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS; + } + return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType, trasferInterest, transferToSavingsId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index dd60c181e..2ce19027e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; @@ -561,6 +562,72 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo .build(); } + /** + * FD Special (monthly interest payout) shows customers/staff exactly how much interest is projected to + * post each month across the account's whole term, rather than a flat rate/12 approximation -- computed + * with the real PostingPeriod engine (same one the actual maturity calculation uses) via + * FixedDepositAccount#projectInterestSchedule, which is deliberately side-effect-free. readOnly=true + * overrides this class's default @Transactional so nothing from that computation is ever flushed. + */ + @Transactional(readOnly = true) + @Override + public List> retrieveProjectedInterestSchedule(final Long savingsId, final DepositAccountType depositAccountType) { + final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType); + if (!(account instanceof FixedDepositAccount)) { + return java.util.Collections.emptyList(); + } + + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(15, MoneyHelper.getRoundingMode()); + + final FixedDepositAccount fdAccount = (FixedDepositAccount) account; + final List postingPeriods = fdAccount.projectInterestSchedule(mc, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + + // determineInterestPostingPeriods always rounds the LAST period out to the full natural boundary + // (e.g. calendar month-end for Monthly posting) even when the account's own maturity date falls + // partway through it -- the INTEREST AMOUNT for that period is still correctly computed only up to + // the real cutoff (interestCalculationUpto = maturityDate - 1, the same day postMaturityInterest + // clamps its own posting date to), but the period's displayed end-date is not, and would otherwise + // show a "To Date" past the account's actual maturity date. Clip it here so the schedule shown + // never claims interest for days beyond maturity, matching the money that is actually calculated. + final LocalDate lastCalculableDate = fdAccount.maturityDate() != null ? fdAccount.maturityDate().minusDays(1) : null; + + // A period is "paid" once a real (non-reversed) interest-posting transaction actually landed for it. + // Matched EXACTLY against that period's own end date (Fineract can date the posting transaction + // either on the period's own last day, or on the first day of the following period, depending on + // the isSavingsInterestPostingAtCurrentPeriodEnd configuration) -- not a date RANGE, which would + // wrongly also match the very next period (whose own start date is that same "day after"). + final List postedInterestDates = new ArrayList<>(); + for (final SavingsAccountTransaction transaction : fdAccount.getTransactions()) { + if (transaction.isInterestPostingAndNotReversed()) { + postedInterestDates.add(transaction.getTransactionDate()); + } + } + + final List> schedule = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + final LocalDate fromDate = period.getPeriodInterval().startDate(); + LocalDate toDate = period.getPeriodInterval().endDate(); + if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) { + toDate = lastCalculableDate; + } + final LocalDate toDateFinal = toDate; + final boolean paid = postedInterestDates.stream() + .anyMatch(postedDate -> postedDate.isEqual(toDateFinal) || postedDate.isEqual(toDateFinal.plusDays(1))); + + final Map row = new HashMap<>(); + row.put("fromDate", fromDate); + row.put("toDate", toDateFinal); + row.put("interestAmount", period.getInterestEarned().getAmount()); + row.put("paid", paid); + schedule.add(row); + } + return schedule; + } + @Transactional @Override public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 7081fcdb7..7b0fcd06d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.group.exception.GroupNotFoundException; import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator; @@ -429,11 +430,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName); AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId, AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue()); + // Tracks whichever savings account ends up linked once this whole block finishes, across every + // branch below (kept as-is, removed, or replaced) -- used afterwards to re-enforce FD Special's + // "must always have a linked account" requirement regardless of what this request touched. + Long resolvedLinkedAccountId = accountAssociations != null && accountAssociations.linkedSavingsAccount() != null + ? accountAssociations.linkedSavingsAccount().getId() + : null; if (savingsAccountId == null) { if (accountAssociations != null) { if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) { this.accountAssociationsRepository.delete(accountAssociations); changes.put(DepositsApiConstants.linkedAccountParamName, null); + resolvedLinkedAccountId = null; if (isLinkedAccRequired) { this.depositAccountDataValidator.throwLinkedAccountRequiredError(); } @@ -465,6 +473,23 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId); this.accountAssociationsRepository.save(accountAssociations); } + resolvedLinkedAccountId = savingsAccountId; + } + + // FD Special is a non-cumulative, monthly-interest-payout product: DepositAccountTermAndPreClosure#update + // above has no fdType awareness and will happily unset transferInterestToLinkedAccount, + // onAccountClosureType and transferToSavingsAccountId on this same request -- including by mere + // omission of maturityInstructionId/transferToSavingsId from the payload -- silently turning a SPECIAL + // FD into a plain cumulative one while it still displays as "FD Special". Re-force the three fields + // here, now that the linked-account resolution above has settled what account (if any) is linked. + if ("SPECIAL".equalsIgnoreCase(account.fdType())) { + if (resolvedLinkedAccountId == null) { + this.depositAccountDataValidator.throwLinkedAccountRequiredError(); + } + account.forceSpecialFdMonthlyPayoutSettings(resolvedLinkedAccountId); + changes.put(transferInterestToSavingsParamName, true); + changes.put(DepositsApiConstants.maturityInstructionIdParamName, DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue()); + this.savingAccountRepository.save(account); } // Replace nominees if provided diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index c9fd58529..edb6c2d02 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -182,6 +182,31 @@ public final class FixedDepositAccountData extends DepositAccountData { this.renewalHistory = renewalHistory; } + /** + * FD Special only: the real, engine-computed interest schedule across the account's whole term -- one entry + * per posting period (fromDate, toDate, interestAmount) -- so staff/customers can see exactly how much + * interest is projected to post each month instead of a flat rate/12 approximation. Null for every other + * FD type (see FixedDepositAccountsApiResource#retrieveOne). + */ + private java.util.List> projectedInterestSchedule; + private BigDecimal totalProjectedInterest; + private BigDecimal totalValueAtMaturity; + + public void setProjectedInterestSchedule(final java.util.List> projectedInterestSchedule) { + this.projectedInterestSchedule = projectedInterestSchedule; + } + + /** + * Server-computed sum of projectedInterestSchedule, and principal + that sum -- the same "what does the + * customer end up with overall" total the account view shows, computed once here instead of the + * frontend re-summing the schedule array itself, so there is a single source of truth (matching how + * maturityAmount itself already works). + */ + public void setProjectedInterestTotals(final BigDecimal totalProjectedInterest, final BigDecimal totalValueAtMaturity) { + this.totalProjectedInterest = totalProjectedInterest; + this.totalValueAtMaturity = totalValueAtMaturity; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index 9f15e9102..0490f3d09 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -340,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom> retrieveProjectedInterestSchedule(Long savingsId, DepositAccountType depositAccountType); + CommandProcessingResult postInterest(Long savingsId, DepositAccountType depositAccountType); CommandProcessingResult undoFDTransaction(Long savingsId, Long transactionId, boolean allowAccountTransferModification); From 179fe3a6ce8b41f88cbeb0e69b47cef239d20b25 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 27 Aug 2026 10:27:58 +0530 Subject: [PATCH 12/55] implement share account actions, view components, and pending checker tasks --- .../constants/ShareAccountApiConstants.java | 31 ++ .../ShareSavingsDebitRequestApiResource.java | 30 +- .../AdditionalSharesFundingApiResource.java | 183 +++++++ .../api/PendingShareAccountsApiResource.java | 107 ++++ .../data/ShareAccountTransactionData.java | 17 +- .../domain/ShareAccountTransaction.java | 106 ++++ .../ShareAccountDataSerializer.java | 153 +++++- .../AdditionalShareTrancheFundingService.java | 475 ++++++++++++++++++ ...urchasedSharesReadPlatformServiceImpl.java | 11 +- .../ShareAccountReadPlatformServiceImpl.java | 11 +- ...WritePlatformServiceJpaRepositoryImpl.java | 15 + .../start/ShareAccountsConfiguration.java | 5 +- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...dd_funding_source_to_share_transaction.xml | 65 +++ ...al_collection_ids_to_share_transaction.xml | 45 ++ ...maker_checker_for_share_account_create.xml | 88 ++++ 16 files changed, 1335 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index c77bf5afa..265157125 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -113,6 +113,37 @@ public interface ShareAccountApiConstants { * outstanding balance. */ String linkedloanid_paramname = "linkedLoanId"; + /** + * Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}. + * + * Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so + * approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They + * differ only in how it got there: CASH means the cashier already took it at the counter + * against a voucher, SAVINGS means the member's own savings account is debited on submit. + */ + String fundingsource_paramname = "fundingSource"; + + String FUNDING_SOURCE_CASH = "CASH"; + + String FUNDING_SOURCE_SAVINGS = "SAVINGS"; + + /** + * The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}. + * + * Recorded against the tranche so one voucher cannot be spent twice, and so the approver can + * see which receipt the money they are releasing came in on. + */ + String advancecollectionid_paramname = "advanceCollectionId"; + + /** + * The remaining vouchers funding a CASH tranche, when one receipt does not cover it. + * + * A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts + * against one request. The first leads in {@link #advancecollectionid_paramname}; these are the + * rest, and their combined total is what has to cover the shares. + */ + String additionalcollectionids_paramname = "additionalCollectionIds"; + Set supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname, id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname, resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java index f80c7fc50..0cc431725 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java @@ -65,11 +65,17 @@ import org.springframework.transaction.annotation.Transactional; * *

  *   Dr  Savings Control     (share value + entry fee)
+ *       Cr  Share Sundry        (share value + entry fee)
+ *
+ *   Dr  Share Sundry        (share value + entry fee)
  *       Cr  Share Capital       (share value)
  *       Cr  Entry Fee Income    (charge, when one is attached)
  * 
* - * No Sundry leg, because nothing is ever held — the debit and the share issue happen together. + * Both steps are posted together on approval, so Sundry nets to zero for the settlement and holds + * nothing afterwards. It is still routed through rather than around: every other way money reaches + * share capital — the cashier's advance collection, and both additional-share routes — passes through + * Sundry, and that balance is what the share ledger reconciles against. * * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair @@ -93,6 +99,8 @@ public class ShareSavingsDebitRequestApiResource { private static final int DEBIT = 2; // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource. + /** Share Sundry — the holding account the member's money passes through on its way to capital. */ + private static final int FA_SHARES_SUSPENSE = 2; private static final int FA_INCOME_FROM_FEES = 3; private static final int FA_SHARES_EQUITY = 4; private static final int PRODUCT_TYPE_SHARES = 4; @@ -350,8 +358,28 @@ public class ShareSavingsDebitRequestApiResource { final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final String txnId = "SHSAV-" + savingsTxnId; + // Routed through Share Sundry rather than straight from savings into share capital, in two + // steps posted together: + // + // 1. Dr Savings Control / Cr Share Sundry — the money leaves the member + // 2. Dr Share Sundry / Cr Share Capital (+ Cr Fee Income) — and is issued as shares + // + // Every other way money reaches share capital passes through Sundry — the cashier's advance + // collection and both additional-share routes — so the Sundry balance is what the whole + // share ledger reconciles against. A settlement that jumped the queue straight to capital + // left that reconciliation permanently short by its own amount, and left no single account + // where money taken for shares but not yet issued could be seen. + final Long glSundry = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share application debited from savings " + savings.get("account_no") + " | " + accountNo); + insertEntry(officeId, glSundry, CREDIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application held in Share Sundry | " + accountNo); + + // The full amount comes back out of Sundry — share value to capital, entry fee to income — + // so Sundry nets to zero for this settlement rather than retaining the fee. + insertEntry(officeId, glSundry, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share Sundry released for " + accountNo); insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share capital issued for " + accountNo); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java new file mode 100644 index 000000000..709959d15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java @@ -0,0 +1,183 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Can a member fund a new additional-share tranche from their savings, and for how much. + * + *

+ * Distinct from {@code /sharesavingsdebit/eligibility}, which answers the same question for an account's first + * application: that endpoint values the settlement by summing every purchase row on the account and treats + * {@code collection_applied} as the account's one settlement. Applied to a further tranche it would quote the member's + * entire holding history and report an already-settled account as ineligible. + * + *

+ * Here the amount is the caller's — the tranche is not saved yet, so there is no row to read it from. The share count is + * passed in and priced at the product's current market price, which is the same figure the apply form shows the member. + */ +@Path("/v1/shareaccounts/{accountId}/additionalshares") +@Component +@Tag(name = "Additional Shares Funding", description = "Whether a new additional-share tranche can be funded from the member's linked savings.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class AdditionalSharesFundingApiResource { + + private static final int SAVINGS_STATUS_ACTIVE = 300; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * @param requestedShares + * how many shares the member wants to buy; the amount is derived from this rather than trusted from the + * caller, so the eligibility answer and the tranche's own value cannot disagree. + */ + @GET + @Path("/savings-eligibility") + @Operation(summary = "Savings eligibility for a new additional-share tranche", description = "Reports the tranche's value and whether the member's linked savings account can cover it.") + public Response savingsEligibility(@PathParam("accountId") final Long accountId, + @QueryParam("requestedShares") final Long requestedShares) { + this.context.authenticatedUser(); + + final Map acct = loadShareAccount(accountId); + final BigDecimal unitPrice = currentMarketPriceOf(acct.get("product_id")); + final long shares = requestedShares == null ? 0L : requestedShares; + final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares)); + + final Map out = new LinkedHashMap<>(); + out.put("shareAccountId", accountId); + out.put("requestedShares", shares); + out.put("unitPrice", unitPrice); + out.put("amount", amount); + + final Object savingsAccountIdRaw = acct.get("savings_account_id"); + if (savingsAccountIdRaw == null) { + return ineligible(out, null, null, "This share account has no linked savings account."); + } + + final Map savings = loadSavingsAccount(toLong(savingsAccountIdRaw)); + final String savingsAccountNo = (String) savings.get("account_no"); + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + out.put("savingsAccountNo", savingsAccountNo); + out.put("savingsBalance", balance); + + if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { + return ineligible(out, savingsAccountNo, balance, "Savings account " + savingsAccountNo + " is not active."); + } + // Zero shares is not a shortfall — the clerk simply has not typed the count yet, and saying + // the balance is short would be wrong as well as unhelpful. + if (shares <= 0) { + return ineligible(out, savingsAccountNo, balance, "Enter the number of shares to check the balance against."); + } + if (balance == null || balance.compareTo(amount) < 0) { + return ineligible(out, savingsAccountNo, balance, + "Savings account " + savingsAccountNo + " does not hold enough to cover this share request."); + } + + out.put("eligible", Boolean.TRUE); + out.put("reason", null); + return Response.ok(gson.toJson(out)).build(); + } + + private Response ineligible(final Map out, final String savingsAccountNo, final BigDecimal balance, + final String reason) { + out.putIfAbsent("savingsAccountNo", savingsAccountNo); + out.putIfAbsent("savingsBalance", balance); + out.put("eligible", Boolean.FALSE); + out.put("reason", reason); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * The product's price on the latest market-price period that has already started, matching how + * {@code ShareProduct.deriveMarketPrice} chooses one; the product's own unit price when no period applies. + */ + private BigDecimal currentMarketPriceOf(final Object productIdRaw) { + final Long productId = toLong(productIdRaw); + final List> periods = this.jdbcTemplate.queryForList(""" + SELECT share_value FROM m_share_product_market_price + WHERE product_id = ? AND from_date <= CURRENT_DATE + ORDER BY from_date DESC LIMIT 1 + """, productId); + if (!periods.isEmpty()) { + final BigDecimal periodPrice = (BigDecimal) periods.get(0).get("share_value"); + if (periodPrice != null) { + return periodPrice; + } + } + final BigDecimal unitPrice = this.jdbcTemplate.queryForObject("SELECT unit_price FROM m_share_product WHERE id = ?", + BigDecimal.class, productId); + return unitPrice == null ? BigDecimal.ZERO : unitPrice; + } + + private Map loadShareAccount(final Long accountId) { + try { + return this.jdbcTemplate.queryForMap( + "SELECT id, account_no, client_id, product_id, status_enum, currency_code, savings_account_id " + + " FROM m_share_account WHERE id = ?", + accountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.share.account.not.found", + "Share account " + accountId + " does not exist."); + } + } + + private Map loadSavingsAccount(final Long savingsAccountId) { + try { + return this.jdbcTemplate.queryForMap( + "SELECT id, account_no, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.share.savings.account.not.found", + "Savings account " + savingsAccountId + " does not exist."); + } + } + + private static Long toLong(final Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + private static int toInt(final Object value) { + return value == null ? 0 : ((Number) value).intValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java new file mode 100644 index 000000000..626e4c300 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java @@ -0,0 +1,107 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Share accounts awaiting approval, for the checker's Pending Share Accounts queue. + * + *

+ * Separate from {@code /accounts/share}, whose {@code retrieveAll} is hard-wired to ACTIVE accounts and returns the full + * account graph — charges, every purchase transaction — per row. A queue needs the opposite: only accounts in + * "submitted and pending approval", and only the handful of fields a checker decides on, so the list stays one cheap + * query however many accounts are waiting. + */ +@Path("/v1/shareaccounts/pending") +@Component +@Tag(name = "Pending Share Accounts", description = "Share accounts submitted and awaiting a checker's approval.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class PendingShareAccountsApiResource { + + /** m_share_account.status_enum for "submitted and pending approval". */ + private static final int STATUS_SUBMITTED_AND_PENDING_APPROVAL = 100; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @GET + @Operation(summary = "List share accounts awaiting approval", description = "Accounts in submitted-and-pending-approval, with the member, product, requested shares and value a checker needs to decide.") + public Response retrievePending() { + this.context.authenticatedUser(); + + // The requested shares come from the account's own pending purchase rows rather than + // total_pending_shares, and are summed: an account can carry more than one pending purchase, + // and the checker is approving the account, so they need to see the whole subscription. + // + // Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so + // queryForList would key the maps "accountno" and "shareaccountid" — which serialise straight + // into the JSON and leave the client reading accountNo/shareAccountId as undefined. + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.submitted_date AS "submittedDate", + sa.resolution_number AS "resolutionNumber", + sa.resolution_date AS "resolutionDate", + au.username AS "submittedByUsername", + COALESCE(txn.requested_shares, 0) AS "requestedShares", + COALESCE(txn.requested_amount, 0) AS "requestedAmount" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + JOIN m_office o ON o.id = c.office_id + JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_appuser au ON au.id = sa.submitted_userid + LEFT JOIN ( + SELECT t.account_id, + SUM(t.total_shares) AS requested_shares, + SUM(t.total_shares * t.unit_price) AS requested_amount + FROM m_share_account_transactions t + WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = 500 + GROUP BY t.account_id + ) txn ON txn.account_id = sa.id + WHERE sa.status_enum = ? + ORDER BY sa.submitted_date DESC, sa.id DESC + """, STATUS_SUBMITTED_AND_PENDING_APPROVAL, STATUS_SUBMITTED_AND_PENDING_APPROVAL); + + return Response.ok(gson.toJson(rows)).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java index 0ce4065fa..93753320e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java @@ -51,10 +51,23 @@ public class ShareAccountTransactionData implements Serializable { private final BigDecimal amountPaid; - /** Board Resolution Number / Date of Meeting that authorised this transaction — populated - * only for a redeem (withdrawal) transaction; null otherwise. */ + /** Board Resolution Number / Date of Meeting that authorised this transaction — recorded on a + * redeem (withdrawal) and on an additional-share purchase; null otherwise. */ private final String resolutionNumber; private final LocalDate meetingDate; + /** How an additional-share tranche was paid for — {@code CASH} or {@code SAVINGS}; null on a + * first application, and on tranches raised before the route was captured. Shown to whoever + * approves the tranche, since approving is what releases the money out of Share Sundry. */ + private final String fundingSource; + + /** The lead voucher funding a CASH tranche; the rest are in {@link #additionalCollectionIds}. */ + private final Long advanceCollectionId; + + /** Further vouchers funding the same tranche, comma-separated; null when one receipt covered it. */ + private final String additionalCollectionIds; + + private final Long savingsDebitRequestId; + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java index 45b314bcc..07b166044 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java @@ -28,8 +28,11 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; @Entity @@ -75,6 +78,36 @@ public class ShareAccountTransaction extends AbstractPersistableCustom { @Column(name = "meeting_date") private LocalDate meetingDate; + /** + * How this tranche was paid for — {@code CASH} or {@code SAVINGS}; null on tranches raised + * before the route was captured. + * + * Both routes park the money in the Share Sundry (SHARES_SUSPENSE) holding account, so + * approving posts the same Dr Sundry / Cr Share Capital pair either way. The route matters on + * the way back out: rejecting a cash tranche releases the voucher to be spent again, while + * rejecting a savings tranche has to refund the member. + */ + @Column(name = "funding_source") + private String fundingSource; + + /** + * The lead {@code m_share_advance_collection} receipt funding a CASH tranche. + * + * "Lead" because a member may pay one tranche in instalments; the rest are in + * {@link #additionalCollectionIds}. Read them together via {@link #getAllAdvanceCollectionIds()} + * rather than this field alone, or the later instalments are missed. + */ + @Column(name = "advance_collection_id") + private Long advanceCollectionId; + + /** The remaining receipts funding this tranche, comma-separated; null when a single one covers it. */ + @Column(name = "additional_collection_ids") + private String additionalCollectionIds; + + /** The {@code m_share_savings_debit_request} behind a SAVINGS tranche. */ + @Column(name = "savings_debit_request_id") + private Long savingsDebitRequestId; + @OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER) private Set shareAccountChargesPaid = new HashSet<>(); @@ -125,6 +158,79 @@ public class ShareAccountTransaction extends AbstractPersistableCustom { return transaction; } + /** + * Records the board resolution authorising a purchase, and how the tranche is funded. + * + * A purchase tranche carries its own resolution, not the account's: each further subscription + * is authorised by its own board meeting. Kept separate from the constructor because a first + * application has no funding route to record — only additional shares are raised this way. + */ + public void updatePurchaseAuthorisationAndFunding(final String resolutionNumber, final LocalDate meetingDate, + final String fundingSource, final List advanceCollectionIds, final Long savingsDebitRequestId) { + this.resolutionNumber = resolutionNumber; + this.meetingDate = meetingDate; + this.fundingSource = fundingSource; + this.savingsDebitRequestId = savingsDebitRequestId; + // First receipt leads; the rest ride along in a single column. Existing single-voucher rows + // keep exactly the shape they had, so nothing reading advance_collection_id has to change. + if (advanceCollectionIds == null || advanceCollectionIds.isEmpty()) { + this.advanceCollectionId = null; + this.additionalCollectionIds = null; + } else { + this.advanceCollectionId = advanceCollectionIds.get(0); + this.additionalCollectionIds = advanceCollectionIds.size() > 1 + ? advanceCollectionIds.subList(1, advanceCollectionIds.size()).stream().map(String::valueOf) + .collect(Collectors.joining(",")) + : null; + } + } + + public String getFundingSource() { + return this.fundingSource; + } + + public Long getAdvanceCollectionId() { + return this.advanceCollectionId; + } + + public String getAdditionalCollectionIds() { + return this.additionalCollectionIds; + } + + /** + * Every receipt funding this tranche, lead first. + * + * The one method that should be used to act on a tranche's vouchers — reserving them, releasing + * them, marking them applied. Using {@code advanceCollectionId} alone would silently skip a + * member's later instalments, leaving their money reserved against nothing or spendable twice. + */ + public List getAllAdvanceCollectionIds() { + final List ids = new ArrayList<>(); + if (this.advanceCollectionId != null) { + ids.add(this.advanceCollectionId); + } + if (this.additionalCollectionIds != null && !this.additionalCollectionIds.isBlank()) { + for (final String part : this.additionalCollectionIds.split(",")) { + final String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + final Long id = Long.valueOf(trimmed); + if (!ids.contains(id)) { + ids.add(id); + } + } + } + } + return ids; + } + + public Long getSavingsDebitRequestId() { + return this.savingsDebitRequestId; + } + + public void updateSavingsDebitRequestId(final Long savingsDebitRequestId) { + this.savingsDebitRequestId = savingsDebitRequestId; + } + public String getResolutionNumber() { return this.resolutionNumber; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 1e1c7dcc1..81229370f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; +import org.apache.fineract.portfolio.shareaccounts.domain.PurchasedSharesStatusType; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountCharge; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy; @@ -110,7 +111,9 @@ public class ShareAccountDataSerializer { private static final Set addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname, ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname, - ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname)); + ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname, + ShareAccountApiConstants.fundingsource_paramname, ShareAccountApiConstants.advancecollectionid_paramname, + ShareAccountApiConstants.additionalcollectionids_paramname)); @Autowired public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper, @@ -746,17 +749,165 @@ public class ShareAccountDataSerializer { .failWithCodeNoParameterAddedToErrorCode("purchase.transaction.date.cannot.be.before.existing.transactions"); } + // The board resolution authorising this tranche. Its own meeting and number: a further + // subscription is a separate decision from the one the account was opened under, so it + // cannot inherit the account's resolution. + final String resolutionNumber = this.fromApiJsonHelper + .extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank(); + final LocalDate meetingDate = this.fromApiJsonHelper + .extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname, element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull(); + + final String fundingSource = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.fundingsource_paramname, element); + // The List overload, not the varargs one: the varargs version lowercases the incoming value + // but compares it against the un-lowercased allowed list, so an upper-case value can never + // match it. The List overload lowercases both sides. + baseDataValidator.reset().parameter(ShareAccountApiConstants.fundingsource_paramname).value(fundingSource).notBlank() + .isOneOfTheseStringValues(List.of(ShareAccountApiConstants.FUNDING_SOURCE_CASH, + ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS)); + + // On the cash route the money is already in Share Sundry against receipts the cashier + // issued, so the tranche has to name them. Validated here rather than at approval: a voucher + // problem found later is found once the member has gone. + // + // A list, not one id: a member often pays a tranche in instalments, and no single receipt + // covers it. They are validated as one settlement, so their combined total is what has to + // cover the shares. + List advanceCollectionIds = List.of(); + if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { + advanceCollectionIds = extractAdvanceCollectionIds(element); + if (advanceCollectionIds.isEmpty()) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull(); + } else { + validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator); + } + } + if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); } final BigDecimal unitPrice = shareProduct.deriveMarketPrice(requestedDate); ShareAccountTransaction purchaseTransaction = new ShareAccountTransaction(requestedDate, sharesRequested, unitPrice); + purchaseTransaction.updatePurchaseAuthorisationAndFunding(resolutionNumber, meetingDate, fundingSource, advanceCollectionIds, null); account.addAdditionalPurchasedShares(purchaseTransaction); handleAdditionalSharesChargeTransactions(account, purchaseTransaction); actualChanges.put(ShareAccountApiConstants.additionalshares_paramname, purchaseTransaction); return actualChanges; } + /** + * The receipts a cash-funded tranche names — the lead one plus any further instalments. + * + * Accepts either shape, so a single-voucher caller need not send a list: {@code advanceCollectionId} + * alone, or that plus {@code additionalCollectionIds}. + */ + private List extractAdvanceCollectionIds(final JsonElement element) { + final List ids = new ArrayList<>(); + final Long leadId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.advancecollectionid_paramname, element); + if (leadId != null) { + ids.add(leadId); + } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.additionalcollectionids_paramname, element)) { + final JsonArray array = this.fromApiJsonHelper + .extractJsonArrayNamed(ShareAccountApiConstants.additionalcollectionids_paramname, element); + if (array != null) { + for (final JsonElement arrayElement : array) { + if (arrayElement != null && !arrayElement.isJsonNull()) { + final Long id = arrayElement.getAsLong(); + if (!ids.contains(id)) { + ids.add(id); + } + } + } + } + } + return ids; + } + + /** + * Checks the Share Sundry receipts a cash-funded tranche names. + * + * Each receipt is checked on its own for the things that make it unusable — it does not exist, + * it belongs to somebody else, it has already been spent, or it is reserved by another pending + * tranche. That last check matters most: without it the same voucher could fund two tranches, so + * Share Sundry would be credited once and released twice. + * + * The value check is the one thing judged across all of them together, because that is how a + * member pays in instalments: ₹600 today and ₹400 tomorrow fund a ₹1,000 tranche, though neither + * receipt covers it alone. + */ + private void validateAdvanceCollectionsForTranche(final List advanceCollectionIds, final ShareAccount account, + final Long sharesRequested, final LocalDate requestedDate, final DataValidatorBuilder baseDataValidator) { + BigDecimal receiptTotal = BigDecimal.ZERO; + + for (final Long advanceCollectionId : advanceCollectionIds) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT receipt_no, voucher_no, client_id, amount, status FROM m_share_advance_collection WHERE id = ?", + advanceCollectionId); + if (rows.isEmpty()) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) + .failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found."); + continue; + } + final Map collection = rows.get(0); + // Named in every message below, because with several receipts in play "the voucher is + // already used" does not say which one to go and look at. + final Object label = collection.get("voucher_no") != null ? collection.get("voucher_no") : collection.get("receipt_no"); + + final Number collectionClientId = (Number) collection.get("client_id"); + if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) + .failWithCode("share.advance.collection.client.mismatch", + "Voucher " + label + " belongs to a different member."); + } + + // Only money still sitting in Sundry can fund a tranche. APPLIED has already become + // share capital somewhere, and REFUNDED went back to the member. + final String status = (String) collection.get("status"); + if (!"UNAPPLIED".equals(status)) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) + .failWithCode("share.advance.collection.not.unapplied", + "Voucher " + label + " is no longer available — it reads " + status + "."); + } + + if (isReservedByAnotherTranche(advanceCollectionId)) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) + .failWithCode("share.advance.collection.already.reserved", + "Voucher " + label + " is already reserved against another pending share request."); + } + + final BigDecimal receiptAmount = (BigDecimal) collection.get("amount"); + if (receiptAmount != null) { + receiptTotal = receiptTotal.add(receiptAmount); + } + } + + final BigDecimal trancheValue = account.getShareProduct().deriveMarketPrice(requestedDate) + .multiply(BigDecimal.valueOf(sharesRequested)); + if (receiptTotal.compareTo(trancheValue) < 0) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal) + .failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal + + ", which does not cover the " + trancheValue + " value of the shares requested."); + } + } + + /** + * Whether this receipt is already earmarked by a pending tranche. + * + * Checks the lead column and the instalment list, because a receipt reserved as somebody's + * second instalment is just as spent as one reserved as a lead. + */ + private boolean isReservedByAnotherTranche(final Long advanceCollectionId) { + final Long count = this.jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_share_account_transactions + WHERE is_active = true AND status_enum = ? + AND (advance_collection_id = ? + OR string_to_array(COALESCE(additional_collection_ids, ''), ',') @> ARRAY[CAST(? AS text)]) + """, Long.class, PurchasedSharesStatusType.APPLIED.getValue(), advanceCollectionId, String.valueOf(advanceCollectionId)); + return count != null && count > 0; + } + private boolean isTransactionBeforeExistingTransactions(LocalDate requestedDate, boolean isTransactionBeforeExistingTransactions, ShareAccount shareAccount) { Collection activeTransactions = shareAccount.getShareAccountTransactions().stream() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java new file mode 100644 index 000000000..366f3bf16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -0,0 +1,475 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.service; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount; +import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Funds one additional-share tranche — moves the member's money into the Share Sundry holding + * account, where it waits for the tranche to be approved. + * + *

+ * Why this is separate from the account-level settlement resources. {@code ShareAdvanceCollectionApiResource} and + * {@code ShareSavingsDebitRequestApiResource} settle an account's first application. Both value the settlement by + * summing every purchase row on the account, and both guard with account-wide state — {@code collection_applied}, and + * one-pending-request-per-account. Neither assumption survives a second tranche: the valuation would charge the member + * for their entire holding history, and the guard would refuse the request outright. So tranche funding is valued and + * recorded per transaction, and leaves that account-level state alone. + * + *

+ * Where the money ends up. Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts + * the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by + * {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only + * gets the money into Sundry in the first place. + * + *

    + *
  • Cash — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche + * leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for + * money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.
  • + *
  • Savings — not there yet, so this debits the member: + * + *
    + *   Dr  Savings Control   (tranche value)
    + *       Cr  Share Sundry      (tranche value)
    + * 
    + * + * The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal + * command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts + * its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are + * reversed and replaced with the pair above.
  • + *
+ * + *

+ * Note that Fineract's stock accounting also posts an APPLIED leg of its own for a new purchase + * (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route + * here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those + * entries when the voucher was issued, and on the savings route the money came from the member's account rather than the + * till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once + * by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once. + */ +@Component +@RequiredArgsConstructor +public class AdditionalShareTrancheFundingService { + + /** Fineract convention: 1 = CREDIT, 2 = DEBIT. */ + private static final int CREDIT = 1; + private static final int DEBIT = 2; + + /** acc_product_mapping.financial_account_type for share products. */ + private static final int FA_SHARES_REFERENCE = 1; + private static final int FA_SHARES_SUSPENSE = 2; + private static final int PRODUCT_TYPE_SHARES = 4; + + /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ + private static final int PRODUCT_TYPE_SAVINGS = 2; + private static final int FA_SAVINGS_CONTROL = 2; + + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */ + private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + /** + * Funds a freshly raised tranche according to the route recorded on it. + * + * Called after the tranche has been saved and flushed, so it has an id to record against, and + * within the apply command's transaction — a tranche that cannot be funded must not exist. + */ + public void fund(final ShareAccount account, final ShareAccountTransaction transaction) { + final String fundingSource = transaction.getFundingSource(); + if (fundingSource == null) { + return; + } + if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { + reserveAdvanceCollection(account, transaction); + } else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) { + debitSavingsIntoSundry(account, transaction); + } + } + + /** + * Settles a tranche's funding once the tranche has been approved and its value released from + * Share Sundry into share capital. + * + * No journal entries of its own — Fineract's APPROVED posting already moved the money. This only + * closes the funding record, so a voucher whose money is now share capital cannot be presented + * again: until this point the receipt was merely reserved, because approval was still refusable. + */ + public void settle(final ShareAccount account, final ShareAccountTransaction transaction) { + if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) { + return; + } + // Every receipt behind the tranche, not just the lead one: a member's later instalments have + // become share capital too, and one left UNAPPLIED could be spent a second time. + for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) { + this.jdbcTemplate.update(""" + UPDATE m_share_advance_collection + SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ? + WHERE id = ? AND status = 'UNAPPLIED' + """, account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), collectionId); + } + } + + /** + * Unwinds the funding of a tranche that was rejected, so the member's money does not sit in + * Share Sundry against shares that will never be issued. + * + * Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits + * Shares Reference, the till, which is only correct for the cash route. The routes part company + * here: + * + *

    + *
  • Cash — nothing to do. The money genuinely came in as cash, so returning it to the + * reference account is right, and releasing the receipt's reservation lets the clerk spend the + * voucher on another tranche or refund it at the counter.
  • + *
  • Savings — the reference credit is wrong: no cash is going back over the counter. + * It is reversed and the member's savings are credited instead, which is where the money came + * from on submit.
  • + *
+ */ + public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) { + final String fundingSource = transaction.getFundingSource(); + if (fundingSource == null) { + return; + } + if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { + releaseAdvanceCollection(transaction); + } else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) { + refundSavings(account, transaction); + } + } + + /** + * Lets the voucher be used again. The receipt itself was never marked APPLIED — approval is + * what would have done that — so only the account it was earmarked against is cleared. + */ + private void releaseAdvanceCollection(final ShareAccountTransaction transaction) { + for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) { + this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = NULL WHERE id = ?", collectionId); + } + } + + /** + * Returns a rejected tranche's money to the savings account it was taken from. + */ + private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) { + if (account.getSavingsAccount() == null) { + return; + } + final BigDecimal amount = transaction.amount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + final Long savingsAccountId = account.getSavingsAccount().getId(); + final Long productId = account.getShareProduct().getId(); + final Long officeId = account.getOfficeId(); + final String currencyCode = account.getCurrency().getCode(); + + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String note = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber(); + + // Fineract's own deposit command, so the refund reaches the member's passbook and statement + // as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in + // over the counter — so the cash debit is replaced by a Sundry debit below. + final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + + // Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the + // till, so that credit is reversed; the Sundry debit it raised alongside is correct and + // stands. + reverseStockReferenceCredit(transaction.getId(), productId, officeId); + + final AppUser currentUser = this.context.authenticatedUser(); + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHTRREF-" + transaction.getId(); + + insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, + "Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber()); + } + + /** + * Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this + * tranche, leaving its Sundry debit standing. + */ + private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) { + final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"); + reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT); + } + + /** + * Reverses this tranche's own unreversed entries on one GL account and one side. + * + * Matched on {@code share_transaction_id} rather than the {@code SH} transaction_id string, + * because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a + * single GL and side is what keeps each reversal to the posting it is meant to undo. + */ + private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) { + this.jdbcTemplate.update(""" + UPDATE acc_gl_journal_entry SET reversed = true + WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false + """, shareTransactionId, glAccountId, officeId, typeEnum); + } + + private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId(); + } + + /** + * Cash route: the money is already in Sundry, so this only ties the receipt to this tranche. + * + * The receipt is left UNAPPLIED. It is not share capital yet — approval is what releases it — + * and marking it APPLIED now would say the money had already become capital. The tranche's own + * {@code advance_collection_id} is what stops it funding a second tranche, checked when the + * next request is validated. + */ + private void reserveAdvanceCollection(final ShareAccount account, final ShareAccountTransaction transaction) { + final List collectionIds = transaction.getAllAdvanceCollectionIds(); + if (collectionIds.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.cash.without.voucher", + "A cash-funded share request must name the voucher the money was received on."); + } + + // Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new + // purchase. On this route the cashier already posted exactly that pair when the voucher was + // issued, so leaving it would count the member's money twice — Sundry credited 200 against a + // 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed: + // submitting a cash-funded tranche moves no money, it only earmarks the receipt. + reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId()); + + for (final Long collectionId : collectionIds) { + this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(), + collectionId); + } + } + + /** + * Savings route: debits the member and credits Share Sundry. + */ + private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) { + if (account.getSavingsAccount() == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings", + "This share account has no linked savings account to debit."); + } + final Long savingsAccountId = account.getSavingsAccount().getId(); + + final BigDecimal amount = transaction.amount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.amount.invalid", + "The value of the shares requested could not be determined."); + } + + final Map savings = loadSavingsAccount(savingsAccountId); + if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active", + "Savings account " + savings.get("account_no") + " is not active."); + } + final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); + if (balance == null || balance.compareTo(amount) < 0) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient", + "Savings account " + savings.get("account_no") + " does not hold enough to cover this share request."); + } + + final Long productId = account.getShareProduct().getId(); + final Long officeId = account.getOfficeId(); + final String currencyCode = account.getCurrency().getCode(); + final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String note = "Additional shares funded from savings | share account " + account.getAccountNumber(); + + // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL + // pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch. + final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + + // Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry, + // assuming cash over the counter. BOTH legs are reversed, not just the reference debit: + // leaving its Sundry credit standing and then posting the savings pair below would credit + // Sundry twice — 200 held against a 100 debit — and the surplus would never clear. + reverseStockAppliedPair(transaction.getId(), productId, officeId); + + final AppUser currentUser = this.context.authenticatedUser(); + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHTRSAV-" + transaction.getId(); + + insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, + "Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber()); + insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, + "Additional shares held in Share Sundry pending approval | " + account.getAccountNumber()); + + transaction.updateSavingsDebitRequestId(savingsTxnId); + } + + /** + * Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this + * tranche, leaving its Sundry credit standing. + * + * Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so + * the Sundry-to-Share-Capital pair raised later at approval is never caught by it. + */ + private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) { + reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT); + reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"), + CREDIT); + } + + private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, + final String note) { + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "yyyy-MM-dd"); + payload.addProperty("transactionDate", txnDate.toString()); + payload.addProperty("transactionAmount", amount); + payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); + payload.addProperty("note", note); + + final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + return result.getResourceId(); + } + + private Long resolveSettlementPaymentTypeId() { + final List> rows = this.jdbcTemplate + .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE); + if (!rows.isEmpty()) { + return toLong(rows.get(0).get("id")); + } + final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1"); + if (fallback.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.no.payment.type", + "No payment types are configured. Add one before funding shares from savings."); + } + return toLong(fallback.get(0).get("id")); + } + + private Map loadSavingsAccount(final Long savingsAccountId) { + return this.jdbcTemplate.queryForMap( + "SELECT id, account_no, status_enum, account_balance_derived, product_id FROM m_savings_account WHERE id = ?", + savingsAccountId); + } + + private Long savingsControlGlOf(final Long savingsAccountId) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT apm.gl_account_id FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 + """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); + return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); + } + + private Long requireShareGl(final Long productId, final int financialAccountType, final String label) { + try { + final Long glId = this.jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + if (glId == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } catch (final IncorrectResultSizeDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped", + label + " GL is not mapped for share product " + productId + "."); + } + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + this.jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", + * which reads back as 00:04 the next day. The instant has to be resolved in the tenant's own + * zone first, which is the zone {@code now} was taken in. + */ + private OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC); + } + + private static Long toLong(final Object value) { + return value == null ? null : ((Number) value).longValue(); + } + + private static int toInt(final Object value) { + return value == null ? 0 : ((Number) value).intValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java index 00f5769d5..5b380801b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java @@ -50,7 +50,9 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe StringBuilder buff = new StringBuilder() .append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ") .append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ") - .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate") + .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ") + .append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ") + .append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId") .append(" from m_share_account_transactions saps "); schema = buff.toString(); } @@ -72,9 +74,14 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe final String resolutionNumber = rs.getString("resolutionNumber"); final java.sql.Date meetingDateSql = rs.getDate("meetingDate"); final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null; + final String fundingSource = rs.getString("fundingSource"); + final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId"); + final String additionalCollectionIds = rs.getString("additionalCollectionIds"); + final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId"); return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum, - amount, chargeAmount, amountPaid, resolutionNumber, meetingDate); + amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId, + additionalCollectionIds, savingsDebitRequestId); } public String schema() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index a77facb3e..2299b20b4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -471,7 +471,9 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat StringBuilder buff = new StringBuilder().append( "saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ") .append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ") - .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate "); + .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ") + .append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ") + .append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId "); schema = buff.toString(); } @@ -493,8 +495,13 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final String resolutionNumber = rs.getString("resolutionNumber"); final java.sql.Date meetingDateSql = rs.getDate("meetingDate"); final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null; + final String fundingSource = rs.getString("fundingSource"); + final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId"); + final String additionalCollectionIds = rs.getString("additionalCollectionIds"); + final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId"); return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum, - amount, chargeAmount, amountPaid, resolutionNumber, meetingDate); + amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId, + additionalCollectionIds, savingsDebitRequestId); } public String schema() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 1c7f1307f..22d3189e7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -74,6 +74,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA private final NoteRepository noteRepository; + /** Moves an additional-share tranche's money into Share Sundry when the tranche is raised. */ + private final AdditionalShareTrancheFundingService trancheFundingService; private final BusinessEventNotifierService businessEventNotifierService; @@ -216,6 +218,9 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA Set transactions = new HashSet<>(); transactions.add(transaction); this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + // After the stock APPLIED posting, not before: the savings route reverses that + // posting's Shares Reference debit, which has to exist to be reversed. + this.trancheFundingService.fund(account, transaction); } } @@ -402,6 +407,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA totalSubscribedShares += transaction.getTotalShares(); } this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + // The APPROVED posting above has released the money from Share Sundry into + // share capital, so the funding behind it can now be closed off. + for (ShareAccountTransaction transaction : transactions) { + this.trancheFundingService.settle(account, transaction); + } } if (!totalSubscribedShares.equals(Long.valueOf(0))) { ShareProduct shareProduct = account.getShareProduct(); @@ -436,6 +446,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA transactions.add(transaction); } this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); + // After the stock REJECTED posting, whose Shares Reference credit the savings + // route reverses in favour of refunding the member. + for (ShareAccountTransaction transaction : transactions) { + this.trancheFundingService.unwind(account, transaction); + } } } return new CommandProcessingResultBuilder() // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index 3d5aa3f2f..bc32af30f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRe import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountPendingEditRepository; import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper; import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer; +import org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService; import org.apache.fineract.portfolio.shareaccounts.service.PurchasedSharesReadPlatformService; import org.apache.fineract.portfolio.shareaccounts.service.PurchasedSharesReadPlatformServiceImpl; import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountChargeReadPlatformService; @@ -125,10 +126,10 @@ public class ShareAccountsConfiguration { ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository, AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository, - BusinessEventNotifierService businessEventNotifierService) { + AdditionalShareTrancheFundingService trancheFundingService, BusinessEventNotifierService businessEventNotifierService) { return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository, accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository, - businessEventNotifierService); + trancheFundingService, businessEventNotifierService); } @Bean diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d896733ae..2c023d719 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -567,4 +567,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml new file mode 100644 index 000000000..f6ee6e4f5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml new file mode 100644 index 000000000..f8f870202 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml new file mode 100644 index 000000000..d88a2ccbe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml @@ -0,0 +1,88 @@ + + + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'SHAREACCOUNT', 'CREATE_SHAREACCOUNT_CHECKER', 'SHAREACCOUNT', 'CREATE_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_SHAREACCOUNT_CHECKER'); + + + + + + + UPDATE m_permission SET can_maker_checker = true WHERE code = 'CREATE_SHAREACCOUNT'; + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'CREATE_SHAREACCOUNT_CHECKER' + AND r.id IN (SELECT rp.role_id FROM m_role_permission rp + JOIN m_permission sp ON sp.id = rp.permission_id + WHERE sp.code = 'CREATE_SAVINGSACCOUNT_CHECKER') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x + WHERE x.role_id = r.id AND x.permission_id = p.id); + + + + From 58187c23d0b8ce5d4b476907dbed6cced34a9877 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 27 Aug 2026 11:26:00 +0530 Subject: [PATCH 13/55] fix(migrations): remove fake test-data seeds, backfill lost permissions, merge redundant roles Migration-hygiene fixes found while auditing whether a fresh database would end up with the same schema/permissions/roles as this dev database. 1. Remove fake test-data seed migrations 0449_seed_aml_test_alerts.xml and 0459_seed_aml_alerts_awaiting_review.xml unconditionally inserted 3 fictional clients (CLIENT_RAJESH/SONIA/VIKRAM) and 5 fake AML alerts against them on every environment, including production if this migration path ever ran there. 0459's own comment confirms it was written purely to exercise the AML review UI. Deleted both files and their entries; cleaned the same fake rows out of this dev database (3 clients, 5 alerts, plus the now-orphaned databasechangelog bookkeeping rows for the two deleted files). 2. Backfill 3 permissions lost to a migration-number collision Auditing all 2712 changesets applied to this dev database against the current codebase found 18 changeset filenames with no matching file anywhere in git; changelog-tenant.xml itself already documents the root cause (multiple branches independently reusing the same numeric prefix for unrelated migrations, e.g. 0456-0462 twice). Cross-checking each affected table/permission against actual Java usage (@CommandType handlers, domain/API classes, and both permission-insert styles used in this codebase -- Liquibase columns and raw literals) showed 15 of the 18 are genuinely dead (reconciliation-batch/settlement- cycle prototype tables, fixed asset/inventory tracking) with zero current code referencing them, so nothing to backfill there. The remaining 3 -- MENU_RECONCILIATION, READ_RECONCILIATION, SIDEBAR_RECONCILIATION -- gate a live module (CashReconciliation / ClearingReconciliation have real API resources and domain classes) but were never seeded by any migration; without them a fresh database can build and expose the reconciliation screens with no way to grant a role access to them. Added 0491_add_reconciliation_menu_permissions.xml. 3. Merge two pairs of redundant roles - "Bank Cashier" (4 fund-transfer permissions, 0 of which overlapped "CASHIER", 0 users assigned) folded into CASHIER. - "AML Officer" (2 permissions, both already present on "Principal Officer", 0 users assigned) folded into Principal Officer. Both migrations copy any missing permissions and move any assigned users onto the surviving role before deleting the redundant one, so they're safe to run against an environment that isn't empty the way this dev database is. Applied the same merge directly to this dev database's m_role / m_role_permission / m_appuser_role rows. Co-Authored-By: Claude Sonnet 5 --- .../db/changelog/tenant/changelog-tenant.xml | 5 +- .../parts/0449_seed_aml_test_alerts.xml | 108 ------------------ .../0459_seed_aml_alerts_awaiting_review.xml | 72 ------------ ...94_add_reconciliation_menu_permissions.xml | 62 ++++++++++ ...5_merge_bank_cashier_role_into_cashier.xml | 81 +++++++++++++ ...ml_officer_role_into_principal_officer.xml | 64 +++++++++++ 6 files changed, 210 insertions(+), 182 deletions(-) delete mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml delete mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_reconciliation_menu_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_merge_bank_cashier_role_into_cashier.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_merge_aml_officer_role_into_principal_officer.xml diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 2c023d719..3e40835df 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -501,7 +501,6 @@ - @@ -523,7 +522,6 @@ - @@ -570,4 +568,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml deleted file mode 100644 index 5749f87d9..000000000 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0449_seed_aml_test_alerts.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - SELECT COUNT(*) FROM m_client WHERE external_id = 'CLIENT_RAJESH' - - - INSERT INTO m_client ( - account_no, external_id, status_enum, office_id, display_name, - fullname, is_staff, activation_date, office_joining_date, - submittedon_date, legal_form_enum, - created_on_utc, created_by, - last_modified_on_utc, last_modified_by, - cif_number - ) - VALUES - ( - 'ACC0000002', 'CLIENT_RAJESH', 300, 1, 'Rajesh Kumar', - 'Rajesh Kumar', false, CURRENT_DATE, CURRENT_DATE, - CURRENT_DATE, 1, - NOW(), 1, - NOW(), 1, - 'CIF_RAJESH' - ), - ( - 'ACC0000003', 'CLIENT_SONIA', 300, 1, 'Sonia Sharma', - 'Sonia Sharma', false, CURRENT_DATE, CURRENT_DATE, - CURRENT_DATE, 1, - NOW(), 1, - NOW(), 1, - 'CIF_SONIA' - ), - ( - 'ACC0000004', 'CLIENT_VIKRAM', 300, 1, 'Vikram Malhotra', - 'Vikram Malhotra', false, CURRENT_DATE, CURRENT_DATE, - CURRENT_DATE, 1, - NOW(), 1, - NOW(), 1, - 'CIF_VIKRAM' - ); - - - - - - - SELECT COUNT(*) FROM m_aml_alert a - JOIN m_client c ON a.client_id = c.id - WHERE c.external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA', 'CLIENT_VIKRAM') - - - - INSERT INTO m_aml_alert ( - transaction_type, transaction_id, client_id, rule_id, amount, currency_code, - raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version - ) - VALUES - ( - 'SAVINGS', 1001, - (SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'), - (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'), - 3500000.000000, 'INR', - NOW(), 'RAISED', - 1, NOW(), 1, NOW(), 0 - ), - ( - 'SAVINGS', 1002, - (SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'), - (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'), - 995000.000000, 'INR', - NOW(), 'RAISED', - 1, NOW(), 1, NOW(), 0 - ), - ( - 'SAVINGS', 1003, - (SELECT id FROM m_client WHERE external_id = 'CLIENT_VIKRAM'), - (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'PEP_ACCOUNT_MONITORING'), - 75000.000000, 'INR', - NOW(), 'RAISED', - 1, NOW(), 1, NOW(), 0 - ); - - - - diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml deleted file mode 100644 index eb2a6b00b..000000000 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - SELECT COUNT(*) FROM m_aml_alert WHERE transaction_id IN (2001, 2002) - - SELECT COUNT(*) FROM m_client WHERE external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA') - - - SELECT COUNT(*) FROM m_aml_transaction_rule - WHERE rule_code IN ('LARGE_CASH_TXN_25L', 'STRUCTURING_BELOW_10L') - - - - INSERT INTO m_aml_alert ( - transaction_type, transaction_id, client_id, rule_id, amount, currency_code, - raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version - ) - VALUES - ( - 'SAVINGS', 2001, - (SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'), - (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'), - 4200000.000000, 'INR', - NOW(), 'RAISED', - 1, NOW(), 1, NOW(), 0 - ), - ( - 'SAVINGS', 2002, - (SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'), - (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'), - 980000.000000, 'INR', - NOW(), 'RAISED', - 1, NOW(), 1, NOW(), 0 - ); - - - - diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_reconciliation_menu_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_reconciliation_menu_permissions.xml new file mode 100644 index 000000000..6334e2a43 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_add_reconciliation_menu_permissions.xml @@ -0,0 +1,62 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_RECONCILIATION' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_merge_bank_cashier_role_into_cashier.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_merge_bank_cashier_role_into_cashier.xml new file mode 100644 index 000000000..9a191c03a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_merge_bank_cashier_role_into_cashier.xml @@ -0,0 +1,81 @@ + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Bank Cashier' + SELECT COUNT(*) FROM m_role WHERE name = 'CASHIER' + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT (SELECT id FROM m_role WHERE name = 'CASHIER'), bc_rp.permission_id + FROM m_role_permission bc_rp + WHERE bc_rp.role_id = (SELECT id FROM m_role WHERE name = 'Bank Cashier') + AND bc_rp.permission_id NOT IN ( + SELECT permission_id FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'CASHIER') + ); + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Bank Cashier' + SELECT COUNT(*) FROM m_role WHERE name = 'CASHIER' + + + INSERT INTO m_appuser_role (appuser_id, role_id) + SELECT bc_ur.appuser_id, (SELECT id FROM m_role WHERE name = 'CASHIER') + FROM m_appuser_role bc_ur + WHERE bc_ur.role_id = (SELECT id FROM m_role WHERE name = 'Bank Cashier') + AND bc_ur.appuser_id NOT IN ( + SELECT appuser_id FROM m_appuser_role + WHERE role_id = (SELECT id FROM m_role WHERE name = 'CASHIER') + ); + + DELETE FROM m_appuser_role + WHERE role_id = (SELECT id FROM m_role WHERE name = 'Bank Cashier'); + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Bank Cashier' + + + DELETE FROM m_role_permission WHERE role_id = (SELECT id FROM m_role WHERE name = 'Bank Cashier'); + DELETE FROM m_role WHERE name = 'Bank Cashier'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_merge_aml_officer_role_into_principal_officer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_merge_aml_officer_role_into_principal_officer.xml new file mode 100644 index 000000000..31d7ed93a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_merge_aml_officer_role_into_principal_officer.xml @@ -0,0 +1,64 @@ + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'AML Officer' + SELECT COUNT(*) FROM m_role WHERE name = 'Principal Officer' + + + INSERT INTO m_appuser_role (appuser_id, role_id) + SELECT ao_ur.appuser_id, (SELECT id FROM m_role WHERE name = 'Principal Officer') + FROM m_appuser_role ao_ur + WHERE ao_ur.role_id = (SELECT id FROM m_role WHERE name = 'AML Officer') + AND ao_ur.appuser_id NOT IN ( + SELECT appuser_id FROM m_appuser_role + WHERE role_id = (SELECT id FROM m_role WHERE name = 'Principal Officer') + ); + + DELETE FROM m_appuser_role + WHERE role_id = (SELECT id FROM m_role WHERE name = 'AML Officer'); + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'AML Officer' + + + DELETE FROM m_role_permission WHERE role_id = (SELECT id FROM m_role WHERE name = 'AML Officer'); + DELETE FROM m_role WHERE name = 'AML Officer'; + + + + From 08519da8b1d22c0e021c769fa0569e7a7d93c5bb Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 27 Aug 2026 17:51:22 +0530 Subject: [PATCH 14/55] feat: implement maker-checker workflow for user creation and update requests --- .../service/CommandSourceService.java | 5 +- .../domain/ConfigurationDomainService.java | 2 + .../domain/ConfigurationDomainServiceJpa.java | 5 + .../api/UsersApiResource.java | 67 +++ .../data/UserPendingRequestData.java | 59 +++ .../domain/UserPendingRequest.java | 113 +++++ .../domain/UserPendingRequestRepository.java | 30 ++ .../UserPendingRequestRepositoryWrapper.java | 49 +++ .../domain/UserPendingRequestStatus.java | 37 ++ .../domain/UserPendingRequestType.java | 36 ++ .../UserPendingRequestNotFoundException.java | 28 ++ .../UserPendingReadPlatformService.java | 30 ++ .../UserPendingReadPlatformServiceImpl.java | 329 +++++++++++++++ .../UserPendingWritePlatformService.java | 33 ++ .../UserPendingWritePlatformServiceImpl.java | 386 ++++++++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 2 + .../0475_enable_admin_user_maker_checker.xml | 77 ++++ ...r_pending_request_and_enable_charge_mc.xml | 98 +++++ .../service/CommandSourceServiceTest.java | 133 ++++++ 19 files changed, 1518 insertions(+), 1 deletion(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index ea8a7294d..591e46473 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -161,7 +161,10 @@ public class CommandSourceService { || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())) { + || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) + || "USER".equalsIgnoreCase(commandSource.getEntityName()) + || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { return false; } return true; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index 18a910266..2529cee37 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -26,6 +26,8 @@ public interface ConfigurationDomainService { boolean isMakerCheckerEnabledForTask(String taskPermissionCode); + boolean isMakerCheckerEnabled(); + /** * Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least * one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index 9056094c5..c393095d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return false; } + @Override + public boolean isMakerCheckerEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled(); + } + @Override public List getAllowedLoanStatusesForExternalAssetTransfer() { final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java index 794a95eca..57744602b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java @@ -88,6 +88,11 @@ public class UsersApiResource { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final BulkImportWorkbookService bulkImportWorkbookService; + private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService; + private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService; + private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper; + private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService; + private final DefaultToApiJsonSerializer pendingRequestApiJsonSerializer; @GET @Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n" @@ -158,6 +163,12 @@ public class UsersApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.configurationDomainService.isMakerCheckerEnabled()) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command); + return this.toApiJsonSerializer.serialize(result); + } final CommandWrapper commandRequest = new CommandWrapperBuilder() // .createUser() // @@ -179,6 +190,12 @@ public class UsersApiResource { @Produces({ MediaType.APPLICATION_JSON }) public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (this.configurationDomainService.isMakerCheckerEnabled()) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command); + return this.toApiJsonSerializer.serialize(result); + } final CommandWrapper commandRequest = new CommandWrapperBuilder() // .updateUser(userId) // @@ -190,6 +207,56 @@ public class UsersApiResource { return this.toApiJsonSerializer.serialize(result); } + @GET + @Path("pending") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List User creations & edits pending approval") + public org.apache.fineract.infrastructure.core.service.Page retrieveAllPendingUsers( + @QueryParam("status") final String status, + @QueryParam("username") final String username, + @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, + @QueryParam("offset") final Integer offset, + @QueryParam("limit") final Integer limit) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit); + } + + @GET + @Path("pending/{id}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a pending user request by ID") + public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.pendingRequestApiJsonSerializer.serialize(settings, data); + } + + @POST + @Path("pending/{id}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a pending User creation or update") + public String approvePendingUser(@PathParam("id") final Long id) { + final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id); + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("pending/{id}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a pending User creation or update") + public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper); + final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command); + return this.toApiJsonSerializer.serialize(result); + } + @POST @Path("{userId}/pwd") @Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java new file mode 100644 index 000000000..dcdf066ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/data/UserPendingRequestData.java @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.data; + +import java.io.Serializable; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserPendingRequestData implements Serializable { + + private static final long serialVersionUID = 1L; + + private final Long id; + private final Long userId; + private final String requestType; + private final String status; + private final String username; + private final String firstname; + private final String lastname; + private final String email; + private final String mobileNo; + private final Long officeId; + private final String officeName; + private final Long staffId; + private final String staffName; + private final String otpDeliveryMethod; + private final Boolean passwordNeverExpires; + private final Collection roleNames; + private final Long makerId; + private final String makerUsername; + private final OffsetDateTime submittedDate; + private final Long checkerId; + private final String checkerUsername; + private final OffsetDateTime checkedDate; + private final String rejectReason; + private final Map originalValues; + private final Map proposedValues; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java new file mode 100644 index 000000000..a143d8598 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequest.java @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_user_pending_request") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Setter +public class UserPendingRequest extends AbstractPersistableCustom { + + @Column(name = "user_id") + private Long userId; + + @Column(name = "request_type", nullable = false, length = 20) + private String requestType; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "payload_json", nullable = false) + private String payloadJson; + + @Column(name = "original_json") + private String originalJson; + + @Column(name = "password_hash") + private String passwordHash; + + @Column(name = "maker_id", nullable = false) + private Long makerId; + + @Column(name = "submitted_date", nullable = false) + private OffsetDateTime submittedDate; + + @Column(name = "checker_id") + private Long checkerId; + + @Column(name = "checked_date") + private OffsetDateTime checkedDate; + + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + @Column(name = "pending_lock_key", length = 100, unique = true) + private String pendingLockKey; + + public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId, + final String payloadJson, final String originalJson, final String passwordHash, + final Long makerId, final String pendingLockKey) { + final UserPendingRequest request = new UserPendingRequest(); + request.requestType = requestType.name(); + request.userId = userId; + request.status = UserPendingRequestStatus.PENDING.name(); + request.payloadJson = payloadJson; + request.originalJson = originalJson; + request.passwordHash = passwordHash; + request.makerId = makerId; + request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC); + request.pendingLockKey = pendingLockKey; + return request; + } + + public boolean isPending() { + return UserPendingRequestStatus.PENDING.name().equals(this.status); + } + + public boolean isMadeBy(final Long appUserId) { + return this.makerId != null && this.makerId.equals(appUserId); + } + + public void approve(final Long checkerId) { + this.status = UserPendingRequestStatus.APPROVED.name(); + this.checkerId = checkerId; + this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC); + this.pendingLockKey = null; + } + + public void reject(final Long checkerId, final String reason) { + this.status = UserPendingRequestStatus.REJECTED.name(); + this.checkerId = checkerId; + this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC); + this.rejectReason = reason; + this.pendingLockKey = null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java new file mode 100644 index 000000000..b622c6c3c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepository.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface UserPendingRequestRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByPendingLockKey(String pendingLockKey); + + boolean existsByPendingLockKey(String pendingLockKey); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java new file mode 100644 index 000000000..8259570c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestRepositoryWrapper.java @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserPendingRequestRepositoryWrapper { + + private final UserPendingRequestRepository repository; + + @Transactional(readOnly = true) + public UserPendingRequest findOneWithNotFoundDetection(final Long id) { + return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id)); + } + + public UserPendingRequest saveAndFlush(final UserPendingRequest request) { + return this.repository.saveAndFlush(request); + } + + public UserPendingRequest save(final UserPendingRequest request) { + return this.repository.save(request); + } + + @Transactional(readOnly = true) + public boolean existsByPendingLockKey(final String pendingLockKey) { + return this.repository.existsByPendingLockKey(pendingLockKey); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java new file mode 100644 index 000000000..2633e4b98 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestStatus.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +public enum UserPendingRequestStatus { + PENDING, + APPROVED, + REJECTED; + + public static UserPendingRequestStatus fromString(final String status) { + if (status == null) { + return null; + } + for (final UserPendingRequestStatus requestStatus : values()) { + if (requestStatus.name().equalsIgnoreCase(status)) { + return requestStatus; + } + } + throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java new file mode 100644 index 000000000..2a9700978 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/UserPendingRequestType.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.domain; + +public enum UserPendingRequestType { + CREATE_USER, + UPDATE_USER; + + public static UserPendingRequestType fromString(final String type) { + if (type == null) { + return null; + } + for (final UserPendingRequestType requestType : values()) { + if (requestType.name().equalsIgnoreCase(type)) { + return requestType; + } + } + throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java new file mode 100644 index 000000000..c2f5b9823 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/exception/UserPendingRequestNotFoundException.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException { + + public UserPendingRequestNotFoundException(final Long id) { + super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java new file mode 100644 index 000000000..4dac903d4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformService.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.useradministration.data.UserPendingRequestData; + +public interface UserPendingReadPlatformService { + + Page retrieveAllPendingRequests(String status, String username, String fromDate, String toDate, + Integer offset, Integer limit); + + UserPendingRequestData retrievePendingRequest(Long id); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java new file mode 100644 index 000000000..08d2f1038 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java @@ -0,0 +1,329 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +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.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.useradministration.data.UserPendingRequestData; +import org.apache.fineract.useradministration.domain.UserPendingRequest; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); + + private static final String SELECT_SQL = """ + SELECT r.id AS id, + r.user_id AS userId, + r.request_type AS requestType, + r.status AS status, + r.payload_json AS payloadJson, + r.original_json AS originalJson, + r.maker_id AS makerId, + maker.username AS makerUsername, + r.submitted_date AS submittedDate, + r.checker_id AS checkerId, + checker.username AS checkerUsername, + r.checked_date AS checkedDate, + r.reject_reason AS rejectReason + FROM m_user_pending_request r + JOIN m_appuser maker ON maker.id = r.maker_id + LEFT JOIN m_appuser checker ON checker.id = r.checker_id + """; + + @Override + @Transactional(readOnly = true) + public Page retrieveAllPendingRequests(final String status, final String username, + final String fromDate, final String toDate, final Integer offset, final Integer limit) { + final StringBuilder where = new StringBuilder(" WHERE 1=1"); + final List args = new ArrayList<>(); + + if (StringUtils.isNotBlank(status)) { + where.append(" AND r.status = ?"); + args.add(status.trim().toUpperCase()); + } else { + where.append(" AND r.status = 'PENDING'"); + } + + if (StringUtils.isNotBlank(fromDate)) { + where.append(" AND r.submitted_date::date >= ?::date"); + args.add(fromDate); + } + if (StringUtils.isNotBlank(toDate)) { + where.append(" AND r.submitted_date::date <= ?::date"); + args.add(toDate); + } + + final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where; + final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + + final int pageOffset = offset == null ? 0 : offset; + final int pageLimit = limit == null ? 20 : limit; + final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?"; + final List pageArgs = new ArrayList<>(args); + pageArgs.add(pageLimit); + pageArgs.add(pageOffset); + + final List items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray()); + + return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); + } + + @Override + @Transactional(readOnly = true) + public UserPendingRequestData retrievePendingRequest(final Long id) { + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + final String sql = SELECT_SQL + " WHERE r.id = ?"; + return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + } + + private static class UserPendingRowMapper implements RowMapper { + + @Override + public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long userId = JdbcSupport.getLong(rs, "userId"); + final String requestType = rs.getString("requestType"); + final String status = rs.getString("status"); + final String payloadJson = rs.getString("payloadJson"); + final Long makerId = rs.getLong("makerId"); + final String makerUsername = rs.getString("makerUsername"); + final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); + final Long checkerId = JdbcSupport.getLong(rs, "checkerId"); + final String checkerUsername = rs.getString("checkerUsername"); + final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); + final String rejectReason = rs.getString("rejectReason"); + + String username = null; + String firstname = null; + String lastname = null; + String email = null; + String mobileNo = null; + Long officeId = null; + String otpDeliveryMethod = null; + + try { + if (payloadJson != null) { + final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject(); + if (payloadObj.has("username")) { + username = payloadObj.get("username").getAsString(); + } + if (payloadObj.has("firstname")) { + firstname = payloadObj.get("firstname").getAsString(); + } + if (payloadObj.has("lastname")) { + lastname = payloadObj.get("lastname").getAsString(); + } + if (payloadObj.has("email")) { + email = payloadObj.get("email").getAsString(); + } + if (payloadObj.has("mobileNo")) { + mobileNo = payloadObj.get("mobileNo").getAsString(); + } + if (payloadObj.has("officeId")) { + officeId = payloadObj.get("officeId").getAsLong(); + } + if (payloadObj.has("otpDeliveryMethod")) { + otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString(); + } + } + } catch (Exception ignored) { + } + + return UserPendingRequestData.builder() + .id(id) + .userId(userId) + .requestType(requestType) + .status(status) + .username(username) + .firstname(firstname) + .lastname(lastname) + .email(email) + .mobileNo(mobileNo) + .officeId(officeId) + .otpDeliveryMethod(otpDeliveryMethod) + .makerId(makerId) + .makerUsername(makerUsername) + .submittedDate(submittedDate) + .checkerId(checkerId) + .checkerUsername(checkerUsername) + .checkedDate(checkedDate) + .rejectReason(rejectReason) + .build(); + } + } + + private static class UserPendingDetailRowMapper implements RowMapper { + + @Override + public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long userId = JdbcSupport.getLong(rs, "userId"); + final String requestType = rs.getString("requestType"); + final String status = rs.getString("status"); + final String payloadJson = rs.getString("payloadJson"); + final String originalJson = rs.getString("originalJson"); + final Long makerId = rs.getLong("makerId"); + final String makerUsername = rs.getString("makerUsername"); + final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); + final Long checkerId = JdbcSupport.getLong(rs, "checkerId"); + final String checkerUsername = rs.getString("checkerUsername"); + final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); + final String rejectReason = rs.getString("rejectReason"); + + final Map proposedValues = new LinkedHashMap<>(); + final Map originalValues = new LinkedHashMap<>(); + + String username = null; + String firstname = null; + String lastname = null; + String email = null; + String mobileNo = null; + Long officeId = null; + String otpDeliveryMethod = null; + Boolean passwordNeverExpires = null; + + try { + if (payloadJson != null) { + final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject(); + for (final String key : payloadObj.keySet()) { + final JsonElement val = payloadObj.get(key); + if ("password".equals(key) || "repeatPassword".equals(key)) { + proposedValues.put("password", "Password will be set on approval"); + } else if (val.isJsonPrimitive()) { + if (val.getAsJsonPrimitive().isBoolean()) { + proposedValues.put(key, val.getAsBoolean()); + } else if (val.getAsJsonPrimitive().isNumber()) { + proposedValues.put(key, val.getAsNumber()); + } else { + proposedValues.put(key, val.getAsString()); + } + } else if (val.isJsonArray()) { + final List list = new ArrayList<>(); + for (final JsonElement elem : val.getAsJsonArray()) { + list.add(elem.getAsString()); + } + proposedValues.put(key, list); + } + } + + if (proposedValues.containsKey("username")) { + username = (String) proposedValues.get("username"); + } + if (proposedValues.containsKey("firstname")) { + firstname = (String) proposedValues.get("firstname"); + } + if (proposedValues.containsKey("lastname")) { + lastname = (String) proposedValues.get("lastname"); + } + if (proposedValues.containsKey("email")) { + email = (String) proposedValues.get("email"); + } + if (proposedValues.containsKey("mobileNo")) { + mobileNo = (String) proposedValues.get("mobileNo"); + } + if (proposedValues.get("officeId") instanceof Number n) { + officeId = n.longValue(); + } + if (proposedValues.containsKey("otpDeliveryMethod")) { + otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod"); + } + if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) { + passwordNeverExpires = b; + } + } + + if (originalJson != null) { + final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject(); + for (final String key : origObj.keySet()) { + final JsonElement val = origObj.get(key); + if (val.isJsonPrimitive()) { + if (val.getAsJsonPrimitive().isBoolean()) { + originalValues.put(key, val.getAsBoolean()); + } else if (val.getAsJsonPrimitive().isNumber()) { + originalValues.put(key, val.getAsNumber()); + } else { + originalValues.put(key, val.getAsString()); + } + } else if (val.isJsonArray()) { + final List list = new ArrayList<>(); + for (final JsonElement elem : val.getAsJsonArray()) { + list.add(elem.getAsString()); + } + originalValues.put(key, list); + } + } + if (proposedValues.containsKey("password")) { + originalValues.put("password", "[PROTECTED]"); + } + } + } catch (Exception ignored) { + } + + return UserPendingRequestData.builder() + .id(id) + .userId(userId) + .requestType(requestType) + .status(status) + .username(username) + .firstname(firstname) + .lastname(lastname) + .email(email) + .mobileNo(mobileNo) + .officeId(officeId) + .otpDeliveryMethod(otpDeliveryMethod) + .passwordNeverExpires(passwordNeverExpires) + .makerId(makerId) + .makerUsername(makerUsername) + .submittedDate(submittedDate) + .checkerId(checkerId) + .checkerUsername(checkerUsername) + .checkedDate(checkedDate) + .rejectReason(rejectReason) + .originalValues(originalValues) + .proposedValues(proposedValues) + .build(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java new file mode 100644 index 000000000..736b23419 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformService.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface UserPendingWritePlatformService { + + CommandProcessingResult submitCreateUser(JsonCommand command); + + CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command); + + CommandProcessingResult approvePendingRequest(Long id); + + CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java new file mode 100644 index 000000000..00c235802 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -0,0 +1,386 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +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.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.useradministration.mfa.domain.MfaFactorType; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.apache.fineract.useradministration.domain.Role; +import org.apache.fineract.useradministration.domain.RoleRepository; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor; +import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; +import org.apache.fineract.useradministration.domain.UserPendingRequest; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepository; +import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; +import org.apache.fineract.useradministration.domain.UserPendingRequestType; +import org.apache.fineract.useradministration.exception.RoleNotFoundException; +import org.apache.fineract.useradministration.exception.UserNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlatformService { + + private final PlatformSecurityContext context; + private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final UserPendingRequestRepository pendingRequestRepository; + private final AppUserRepository appUserRepository; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final RoleRepository roleRepository; + private final StaffRepositoryWrapper staffRepositoryWrapper; + private final ClientRepositoryWrapper clientRepositoryWrapper; + private final UserMfaFactorRepository userMfaFactorRepository; + private final ConfigurationDomainService configurationDomainService; + private final PasswordEncoder passwordEncoder; + private final UserDataValidator fromApiJsonDeserializer; + private final FromJsonHelper fromApiJsonHelper; + + @Override + @Transactional + public CommandProcessingResult submitCreateUser(final JsonCommand command) { + final AppUser maker = this.context.authenticatedUser(); + + this.fromApiJsonDeserializer.validateForCreate(command.json()); + + final String username = command.stringValueOfParameterNamed("username"); + final String email = command.stringValueOfParameterNamed("email"); + final String mobileNo = command.stringValueOfParameterNamed("mobileNo"); + + // Pre-flight checks on live users and pending creations + if (this.appUserRepository.findAppUserByName(username) != null) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.username", + "User with username " + username + " already exists.", "username", username); + } + + final String lockKey = "CREATE_USER:" + username.toLowerCase(); + if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate", + "A pending creation request for username '" + username + "' is already awaiting checker approval.", username); + } + + assertEmailAndMobileUnique(email, mobileNo, null); + + final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject(); + String passwordHash = null; + if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) { + final String rawPassword = jsonObject.get("password").getAsString(); + if (!rawPassword.isBlank()) { + passwordHash = this.passwordEncoder.encode(rawPassword); + } + jsonObject.addProperty("password", "[PROTECTED]"); + if (jsonObject.has("repeatPassword")) { + jsonObject.addProperty("repeatPassword", "[PROTECTED]"); + } + } + + final String sanitizedPayload = jsonObject.toString(); + final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.CREATE_USER, null, + sanitizedPayload, null, passwordHash, maker.getId(), lockKey); + + final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(saved.getId()) + .setRollbackTransaction(true) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult submitUpdateUser(final Long userId, final JsonCommand command) { + final AppUser maker = this.context.authenticatedUser(); + final AppUser userToUpdate = this.appUserRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + + this.fromApiJsonDeserializer.validateForUpdate(command.json(), maker); + + final String email = command.stringValueOfParameterNamed("email"); + final String mobileNo = command.stringValueOfParameterNamed("mobileNo"); + assertEmailAndMobileUnique(email, mobileNo, userId); + + final String lockKey = "UPDATE_USER:" + userId; + if (this.pendingRequestRepository.existsByPendingLockKey(lockKey)) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.duplicate", + "A pending update request for user ID " + userId + " is already awaiting checker approval.", userId); + } + + // Build point-in-time original JSON snapshot + final Map originalSnapshot = new LinkedHashMap<>(); + originalSnapshot.put("username", userToUpdate.getUsername()); + originalSnapshot.put("firstname", userToUpdate.getFirstname()); + originalSnapshot.put("lastname", userToUpdate.getLastname()); + originalSnapshot.put("email", userToUpdate.getEmail()); + originalSnapshot.put("mobileNo", userToUpdate.getMobileNo()); + originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null); + originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null); + originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null); + originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires()); + originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser()); + final Set roleNames = new HashSet<>(); + final Set roleIds = new HashSet<>(); + if (userToUpdate.getRoles() != null) { + for (final Role r : userToUpdate.getRoles()) { + roleNames.add(r.getName()); + roleIds.add(r.getId()); + } + } + originalSnapshot.put("roles", roleIds); + originalSnapshot.put("roleNames", roleNames); + final String originalJson = GoogleGsonSerializerHelper.createSimpleGson().toJson(originalSnapshot); + + final JsonObject jsonObject = JsonParser.parseString(command.json()).getAsJsonObject(); + String passwordHash = null; + if (jsonObject.has("password") && !jsonObject.get("password").isJsonNull()) { + final String rawPassword = jsonObject.get("password").getAsString(); + if (!rawPassword.isBlank()) { + passwordHash = this.passwordEncoder.encode(rawPassword); + } + jsonObject.addProperty("password", "[PROTECTED]"); + if (jsonObject.has("repeatPassword")) { + jsonObject.addProperty("repeatPassword", "[PROTECTED]"); + } + } + + final String sanitizedPayload = jsonObject.toString(); + final UserPendingRequest request = UserPendingRequest.create(UserPendingRequestType.UPDATE_USER, userId, + sanitizedPayload, originalJson, passwordHash, maker.getId(), lockKey); + + final UserPendingRequest saved = this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(saved.getId()) + .setRollbackTransaction(true) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult approvePendingRequest(final Long id) { + final AppUser checker = this.context.authenticatedUser(); + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + + if (!request.isPending()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending", + "User pending request " + id + " has already been actioned.", id); + } + + if (request.isMadeBy(checker.getId())) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval", + "You cannot approve a user request you submitted yourself — a different user must action it.", id); + } + + final Long resourceId; + if (UserPendingRequestType.CREATE_USER.name().equals(request.getRequestType())) { + resourceId = finalizeCreateUser(request); + } else if (UserPendingRequestType.UPDATE_USER.name().equals(request.getRequestType())) { + resourceId = finalizeUpdateUser(request); + } else { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.type.invalid", + "Unknown request type: " + request.getRequestType(), request.getRequestType()); + } + + request.approve(checker.getId()); + this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(resourceId) + .withResourceIdAsString(resourceId.toString()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectPendingRequest(final Long id, final JsonCommand command) { + final AppUser checker = this.context.authenticatedUser(); + final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); + + if (!request.isPending()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.not.pending", + "User pending request " + id + " has already been actioned.", id); + } + + if (request.isMadeBy(checker.getId())) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.self.approval", + "You cannot reject a user request you submitted yourself — a different user must action it.", id); + } + + String reason = command != null ? command.stringValueOfParameterNamedAllowingNull("reason") : null; + if (reason == null && command != null) { + reason = command.stringValueOfParameterNamedAllowingNull("rejectReason"); + } + if (reason == null || reason.isBlank()) { + throw new GeneralPlatformDomainRuleException("error.msg.userpendingrequest.reject.reason.required", + "A reason is required to reject a user pending request.", id); + } + + request.reject(checker.getId(), reason); + this.pendingRequestRepositoryWrapper.saveAndFlush(request); + + return new CommandProcessingResultBuilder() + .withEntityId(request.getId()) + .withResourceIdAsString(request.getId().toString()) + .build(); + } + + private Long finalizeCreateUser(final UserPendingRequest request) { + final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + final JsonCommand replay = JsonCommand.fromJsonElement(null, parsedElement, this.fromApiJsonHelper); + + final Long officeId = replay.longValueOfParameterNamed("officeId"); + final Office userOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + final String[] roles = replay.arrayValueOfParameterNamed("roles"); + final Set allRoles = assembleSetOfRoles(roles); + + final Long staffId = replay.longValueOfParameterNamed("staffId"); + final Staff linkedStaff = staffId != null + ? this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userOffice.getId()) + : null; + + final Collection clients = null; + + final AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, replay); + + if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) { + appUser.updatePasswordResetRequired(true); + } + + // Set pre-hashed password + if (request.getPasswordHash() != null) { + appUser.updatePassword(request.getPasswordHash()); + } + + this.appUserRepository.saveAndFlush(appUser); + + // MFA Enrollment + final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod"); + if (otpDeliveryMethod != null) { + final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod); + final UserMfaFactor factor = new UserMfaFactor(); + factor.setAppUser(appUser); + factor.setFactorType(factorType); + factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail()); + factor.setVerified(true); + factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC)); + this.userMfaFactorRepository.save(factor); + } + + return appUser.getId(); + } + + private Long finalizeUpdateUser(final UserPendingRequest request) { + final AppUser userToUpdate = this.appUserRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserNotFoundException(request.getUserId())); + + final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper); + + final Map changes = userToUpdate.update(replay, null, null); + + if (changes.containsKey("officeId")) { + final Long officeId = (Long) changes.get("officeId"); + final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + userToUpdate.changeOffice(office); + } + if (changes.containsKey("staffId")) { + final Long staffId = (Long) changes.get("staffId"); + Staff linkedStaff = null; + if (staffId != null) { + linkedStaff = this.staffRepositoryWrapper.findByOfficeWithNotFoundDetection(staffId, userToUpdate.getOffice().getId()); + } + userToUpdate.changeStaff(linkedStaff); + } + if (changes.containsKey("roles")) { + final String[] roleIds = (String[]) changes.get("roles"); + final Set allRoles = assembleSetOfRoles(roleIds); + userToUpdate.updateRoles(allRoles); + } + if (request.getPasswordHash() != null) { + userToUpdate.updatePassword(request.getPasswordHash()); + } + + this.appUserRepository.saveAndFlush(userToUpdate); + return userToUpdate.getId(); + } + + private Set assembleSetOfRoles(final String[] rolesArray) { + final Set allRoles = new HashSet<>(); + if (!ObjectUtils.isEmpty(rolesArray)) { + for (final String roleId : rolesArray) { + final Long id = Long.valueOf(roleId); + final Role role = this.roleRepository.findById(id).orElseThrow(() -> new RoleNotFoundException(id)); + allRoles.add(role); + } + } + return allRoles; + } + + private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) { + if (email != null && !email.isBlank()) { + final java.util.Optional emailOwner = excludeUserId == null + ? this.appUserRepository.findByEmailIgnoreCase(email) + : this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId); + if (emailOwner.isPresent()) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.email", + "User with email " + email + " already exists.", "email", email); + } + } + if (mobileNo != null && !mobileNo.isBlank()) { + final java.util.Optional mobileOwner = excludeUserId == null + ? this.appUserRepository.findByMobileNo(mobileNo) + : this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId); + if (mobileOwner.isPresent()) { + throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile", + "User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo); + } + } + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 8fd705c9f..7a7b339af 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -543,4 +543,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml new file mode 100644 index 000000000..9ddde27a5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_enable_admin_user_maker_checker.xml @@ -0,0 +1,77 @@ + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'authorisation', 'PERMISSIONS_ROLE_CHECKER', 'ROLE', 'PERMISSIONS_CHECKER', FALSE + WHERE NOT EXISTS ( + SELECT 1 FROM m_permission WHERE code = 'PERMISSIONS_ROLE_CHECKER' + ); + + + + + ANY + + + SELECT COUNT(*) + FROM m_permission + WHERE code IN ( + 'CREATE_USER', 'UPDATE_USER', 'DELETE_USER', + 'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE', + 'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE' + ) + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code IN ( + 'CREATE_USER', 'UPDATE_USER', 'DELETE_USER', + 'CREATE_ROLE', 'UPDATE_ROLE', 'PERMISSIONS_ROLE', + 'DELETE_ROLE', 'ENABLE_ROLE', 'DISABLE_ROLE' + ); + + + + + ANY + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ( + 'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER', + 'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER', + 'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ( + 'CREATE_USER_CHECKER', 'UPDATE_USER_CHECKER', 'DELETE_USER_CHECKER', + 'CREATE_ROLE_CHECKER', 'UPDATE_ROLE_CHECKER', 'PERMISSIONS_ROLE_CHECKER', + 'DELETE_ROLE_CHECKER', 'ENABLE_ROLE_CHECKER', 'DISABLE_ROLE_CHECKER' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml new file mode 100644 index 000000000..9d0c6db5a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_user_pending_request_and_enable_charge_mc.xml @@ -0,0 +1,98 @@ + + + + + ANY + + CREATE TABLE IF NOT EXISTS m_user_pending_request ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NULL REFERENCES m_appuser(id) ON DELETE SET NULL, + request_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + payload_json TEXT NOT NULL, + original_json TEXT NULL, + password_hash VARCHAR(255) NULL, + maker_id BIGINT NOT NULL REFERENCES m_appuser(id), + submitted_date TIMESTAMPTZ NOT NULL, + checker_id BIGINT NULL REFERENCES m_appuser(id), + checked_date TIMESTAMPTZ NULL, + reject_reason VARCHAR(500) NULL, + pending_lock_key VARCHAR(100) NULL UNIQUE + ); + + CREATE INDEX IF NOT EXISTS idx_user_pending_req_status ON m_user_pending_request(status); + CREATE INDEX IF NOT EXISTS idx_user_pending_req_user_id ON m_user_pending_request(user_id); + CREATE INDEX IF NOT EXISTS idx_user_pending_req_maker ON m_user_pending_request(maker_id); + + + + + ANY + + -- Disable native MC on CREATE_USER and UPDATE_USER to avoid core password sanitization error + UPDATE m_permission + SET can_maker_checker = FALSE + WHERE code IN ('CREATE_USER', 'UPDATE_USER'); + + -- Enable native MC on UPDATE_CHARGE and DELETE_CHARGE + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code IN ('UPDATE_CHARGE', 'DELETE_CHARGE'); + + -- Insert bespoke user maker-checker permissions + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'SUBMIT_CREATE_USER', 'USER', 'SUBMIT_CREATE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_CREATE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'SUBMIT_UPDATE_USER', 'USER', 'SUBMIT_UPDATE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'SUBMIT_UPDATE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'APPROVE_USER', 'USER', 'APPROVE', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'APPROVE_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'REJECT_USER', 'USER', 'REJECT', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REJECT_USER'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'user_administration', 'READ_USER_PENDING_REQUEST', 'USER', 'READ_PENDING', FALSE + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USER_PENDING_REQUEST'); + + -- Grant permissions to Super user + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ( + 'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER', + 'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + -- Grant permissions to bankadmin + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ( + 'UPDATE_CHARGE', 'UPDATE_CHARGE_CHECKER', 'DELETE_CHARGE', 'DELETE_CHARGE_CHECKER', + 'SUBMIT_CREATE_USER', 'SUBMIT_UPDATE_USER', 'APPROVE_USER', 'REJECT_USER', 'READ_USER_PENDING_REQUEST' + ) + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index 2e34d1fe0..c4db9dadf 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -484,6 +484,55 @@ public class CommandSourceServiceTest { Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); } + @Test + public void testProcessCommand_FixedDepositProduct_SuperUser_EnforcesMakerChecker() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT"); + Mockito.when(commandSource.getEntityName()).thenReturn("FIXEDDEPOSITPRODUCT"); + Mockito.when(commandSource.getActionName()).thenReturn("CREATE"); + Mockito.when(commandSource.getId()).thenReturn(114L); + Mockito.when(commandSource.getResourceId()).thenReturn(214L); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + + @Test + public void testProcessCommand_FixedDepositProduct_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_FIXEDDEPOSITPRODUCT"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_FIXEDDEPOSITPRODUCT")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } + @Test public void testProcessCommand_Products_CheckerApproval_MarksAsChecked() { NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); @@ -505,4 +554,88 @@ public class CommandSourceServiceTest { Mockito.verify(commandSource).markAsChecked(checkerUser); Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); } + + @Test + public void testProcessCommand_UserOperations_SuperUserBypassBlocked_ThrowsRollbackException() { + String[] actions = { "CREATE", "UPDATE", "DELETE" }; + String[] permissions = { "CREATE_USER", "UPDATE_USER", "DELETE_USER" }; + + for (int i = 0; i < actions.length; i++) { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getEntityName()).thenReturn("USER"); + Mockito.when(commandSource.getActionName()).thenReturn(actions[i]); + Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]); + Mockito.when(commandSource.isSanitized()).thenReturn(false); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + } + + @Test + public void testProcessCommand_RoleOperations_SuperUserBypassBlocked_ThrowsRollbackException() { + String[] actions = { "CREATE", "UPDATE", "PERMISSIONS", "DELETE" }; + String[] permissions = { "CREATE_ROLE", "UPDATE_ROLE", "PERMISSIONS_ROLE", "DELETE_ROLE" }; + + for (int i = 0; i < actions.length; i++) { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser superUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getEntityName()).thenReturn("ROLE"); + Mockito.when(commandSource.getActionName()).thenReturn(actions[i]); + Mockito.when(commandSource.getPermissionCode()).thenReturn(permissions[i]); + Mockito.when(commandSource.isSanitized()).thenReturn(false); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask(permissions[i])).thenReturn(true); + Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); + + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); + } + } + + @Test + public void testProcessCommand_UserAndRole_CheckerApproval_MarksAsChecked() { + NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); + JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); + CommandSource commandSource = Mockito.mock(CommandSource.class); + AppUser checkerUser = Mockito.mock(AppUser.class); + + Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_USER"); + Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_USER")).thenReturn(true); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder() + .withCommandId(1L).build(); + Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); + + org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, + commandSource, checkerUser, true); + + Assertions.assertNotNull(result); + Mockito.verify(commandSource).markAsChecked(checkerUser); + Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + } } From 1bc9d0901d9cdefa50f791d14f216df3fd45b7a4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 27 Aug 2026 18:41:42 +0530 Subject: [PATCH 15/55] feat(savings): facility charge mapping admin API, enforce mapped-charge validation, INR-only currency, GL chart backfill Adds the admin read/write API for m_facility_charge_mapping (list, template, update) so facilities can be pointed at a real charge instead of relying on the seeder's implicit auto-link, and makes enable-on-account paths (activation and edit-approval, both savings and current accounts) throw instead of silently skipping the charge when a facility isn't mapped or is inactive. Migrations 0497-0509 back this up: reset all facility mappings to unmapped/inactive by default, backfill lost roles/permissions/charges, enforce INR as the only organisation currency, backfill the full real-world chart of accounts (44 core + 60 priority GL heads) with standardized gl_codes, and retire the unused system-defined payment types (Share Redemption Transfer and friends) in favor of Cash/Transfer only, matching the teller dashboard's simplified payment-type flow. Co-Authored-By: Claude Sonnet 5 --- .../service/CommandWrapperBuilder.java | 8 + ...lerkShareWithdrawalRequestApiResource.java | 6 +- .../FacilityChargeMappingsApiResource.java | 94 +++ ...teFacilityChargeMappingCommandHandler.java | 42 ++ ...urrentAccountWritePlatformServiceImpl.java | 17 +- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 3 + .../tenant/final-changelog-tenant.xml | 10 + .../tenant/parts/0497_backfill_lost_roles.xml | 218 ++++++ .../parts/0498_backfill_required_charges.xml | 106 +++ ...dd_facility_charge_mapping_permissions.xml | 55 ++ ...t_facility_charge_mappings_to_unmapped.xml | 40 ++ ...enforce_inr_only_organisation_currency.xml | 50 ++ .../0502_backfill_core_chart_of_accounts.xml | 196 +++++ .../0503_standardize_gl_account_codes.xml | 89 +++ .../parts/0504_add_priority_gl_accounts.xml | 200 ++++++ ...backfill_missing_codes_and_code_values.xml | 674 ++++++++++++++++++ ..._context_default_client_kyc_migrations.xml | 534 ++++++++++++++ .../0507_simplify_teller_payment_types.xml | 72 ++ ...share_redemption_transfer_payment_type.xml | 60 ++ ...etire_all_system_defined_payment_types.xml | 69 ++ .../data/FacilityChargeMappingData.java | 83 +++ ...acilityChargeMappingNotFoundException.java | 28 + .../FacilityChargeAutoApplyService.java | 78 +- ...ilityChargeMappingReadPlatformService.java | 32 + ...yChargeMappingReadPlatformServiceImpl.java | 78 ++ ...lityChargeMappingWritePlatformService.java | 27 + ...ChargeMappingWritePlatformServiceImpl.java | 102 +++ .../service/FacilityChargeSeederService.java | 52 +- 29 files changed, 2957 insertions(+), 70 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FacilityChargeMappingsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/UpdateFacilityChargeMappingCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_lost_roles.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_backfill_required_charges.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_facility_charge_mapping_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_reset_facility_charge_mappings_to_unmapped.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_enforce_inr_only_organisation_currency.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_standardize_gl_account_codes.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_priority_gl_accounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_backfill_missing_codes_and_code_values.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_catchup_context_default_client_kyc_migrations.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_simplify_teller_payment_types.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_retire_share_redemption_transfer_payment_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_retire_all_system_defined_payment_types.xml create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FacilityChargeMappingData.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/FacilityChargeMappingNotFoundException.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformService.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformServiceImpl.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformService.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformServiceImpl.java diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index ae8f7d0b9..24adb21c5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -3112,6 +3112,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) { + this.actionName = "UPDATE"; + this.entityName = "FACILITYCHARGEMAPPING"; + this.entityId = mappingId; + this.href = "/facilitychargemappings/" + mappingId; + return this; + } + public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) { this.actionName = "DELETE"; this.entityName = "FINANCIALACTIVITYACCOUNT"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 75f97f2a4..30dbe7b23 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -91,7 +91,11 @@ public class ClerkShareWithdrawalRequestApiResource { "(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name", "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" }; - private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER"; + // "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of + // the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a + // pre-settlement_payment_type_id-column legacy request still resolves to a real payment type + // instead of silently posting its settlement with none at all. + private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER"; // ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ─── diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FacilityChargeMappingsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FacilityChargeMappingsApiResource.java new file mode 100644 index 000000000..a09b6c09d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FacilityChargeMappingsApiResource.java @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.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.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.List; +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.savings.data.FacilityChargeMappingData; +import org.apache.fineract.portfolio.savings.service.FacilityChargeMappingReadPlatformService; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge} + * is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled on a savings/current + * account. The 16 facility rows themselves are fixed and auto-seeded by FacilityChargeSeederService at startup, + * so this resource only ever reads them and updates which charge one maps to -- there is no create/delete. + */ +@Path("/v1/facilitychargemappings") +@Component +@Tag(name = "Facility Charge Mappings", description = """ + Which charge is applied when a named account facility (ATM Facility, UPI Facility, Cheque Book, ...) \ + is enabled on a savings or current account. Enabling a facility whose mapping has no charge configured \ + is rejected -- configure the mapping here first.""") +@RequiredArgsConstructor +public class FacilityChargeMappingsApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "FACILITYCHARGEMAPPING"; + + private final PlatformSecurityContext context; + private final FacilityChargeMappingReadPlatformService readPlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List Facility Charge Mappings", description = "Example Requests: facilitychargemappings") + public List retrieveAll() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + return readPlatformService.retrieveAll(); + } + + @GET + @Path("template") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Facility Charge Mapping Template", description = "Charge options available for mapping to a facility") + public FacilityChargeMappingData retrieveTemplate() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + return readPlatformService.retrieveTemplate(); + } + + @PUT + @Path("{mappingId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update a Facility Charge Mapping", description = "Changes which charge is applied when the facility is enabled") + public CommandProcessingResult update(@PathParam("mappingId") @Parameter(description = "mappingId") final Long mappingId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateFacilityChargeMapping(mappingId) + .withJson(apiRequestBodyAsJson).build(); + + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/UpdateFacilityChargeMappingCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/UpdateFacilityChargeMappingCommandHandler.java new file mode 100644 index 000000000..76ec0e396 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/UpdateFacilityChargeMappingCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.handler; + +import lombok.RequiredArgsConstructor; +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.savings.service.FacilityChargeMappingWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "FACILITYCHARGEMAPPING", action = "UPDATE") +@RequiredArgsConstructor +public class UpdateFacilityChargeMappingCommandHandler implements NewCommandSourceHandler { + + private final FacilityChargeMappingWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.updateMapping(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index a5a221774..c6ee7f823 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j; 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.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -636,14 +637,20 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri for (final String facilityType : newlyEnabledFacilityTypes) { final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); - if (mappingOpt.isEmpty()) { - log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping", + // An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's + // requireMappedCharge for why. + if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", + "A charge has not been mapped for the '" + facilityType + + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", facilityType); - continue; } final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); - if (charge == null) { - continue; + if (charge.isDeleted() || !charge.isActive()) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", + "The charge mapped to the '" + facilityType + + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", + facilityType); } final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 70fbe79ba..e39a6cdd6 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 3e40835df..f51d9bc71 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -571,4 +571,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 27b3335ca..fceb9115a 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -23,4 +23,14 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_lost_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_lost_roles.xml new file mode 100644 index 000000000..b4b138625 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_backfill_lost_roles.xml @@ -0,0 +1,218 @@ + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Teller' + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Loan Officer' + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'As Manager' + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'ALL HEADER MENU' + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'LEDGER' + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Teller' + + SELECT COUNT(*) FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'Teller') + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT (SELECT id FROM m_role WHERE name = 'Teller'), p.id + FROM m_permission p + WHERE p.code IN ( + 'ALLOCATECASHIER_TELLER', + 'ALLOCATECASHIER_TELLER_CHECKER', + 'ALLOCATECASHTOCASHIER_TELLER', + 'ALLOCATECASHTOCASHIER_TELLER_CHECKER', + 'CREATE_TELLER', + 'CREATE_TELLER_CHECKER', + 'DELETE_TELLER', + 'DELETE_TELLER_CHECKER', + 'DELETECASHIERALLOCATION_TELLER', + 'DELETECASHIERALLOCATION_TELLER_CHECKER', + 'SETTLECASHFROMCASHIER_TELLER', + 'SETTLECASHFROMCASHIER_TELLER_CHECKER', + 'UPDATE_TELLER', + 'UPDATE_TELLER_CHECKER', + 'UPDATECASHIERALLOCATION_TELLER', + 'UPDATECASHIERALLOCATION_TELLER_CHECKER' + ); + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'As Manager' + + SELECT COUNT(*) FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager') + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT (SELECT id FROM m_role WHERE name = 'As Manager'), p.id + FROM m_permission p + WHERE p.code IN ( + 'ADJUST_LOAN', + 'ADJUST_LOAN_CHECKER', + 'ALLOCATECASHIER_TELLER', + 'ALLOCATECASHIER_TELLER_CHECKER', + 'ALLOCATECASHTOCASHIER_TELLER', + 'ALLOCATECASHTOCASHIER_TELLER_CHECKER', + 'APPROVE_LOAN', + 'APPROVE_LOAN_CHECKER', + 'BULKREASSIGN_LOAN', + 'BULKREASSIGN_LOAN_CHECKER', + 'CLOSE_LOAN', + 'CLOSE_LOAN_CHECKER', + 'CLOSEASRESCHEDULED_LOAN', + 'CLOSEASRESCHEDULED_LOAN_CHECKER', + 'CREATE_TELLER', + 'CREATE_TELLER_CHECKER', + 'DELETE_TELLER', + 'DELETE_TELLER_CHECKER', + 'DELETECASHIERALLOCATION_TELLER', + 'DELETECASHIERALLOCATION_TELLER_CHECKER', + 'DISBURSALLASTUNDO_LOAN', + 'DISBURSALLASTUNDO_LOAN_CHECKER', + 'DISBURSALUNDO_LOAN', + 'DISBURSALUNDO_LOAN_CHECKER', + 'DISBURSE_LOAN', + 'DISBURSE_LOAN_CHECKER', + 'DISBURSETOSAVINGS_LOAN', + 'MENU_BRANCH_OPS', + 'RECOVERYPAYMENT_LOAN', + 'REFUNDBYCASH_LOAN', + 'REFUNDBYCASH_LOAN_CHECKER', + 'REJECT_LOAN', + 'REJECT_LOAN_CHECKER', + 'REMOVELOANOFFICER_LOAN', + 'REMOVELOANOFFICER_LOAN_CHECKER', + 'REPAYMENT_LOAN', + 'REPAYMENT_LOAN_CHECKER', + 'SETTLECASHFROMCASHIER_TELLER', + 'SETTLECASHFROMCASHIER_TELLER_CHECKER', + 'UNDOWRITEOFF_LOAN', + 'UPDATE_TELLER', + 'UPDATE_TELLER_CHECKER', + 'UPDATECASHIERALLOCATION_TELLER', + 'UPDATECASHIERALLOCATION_TELLER_CHECKER', + 'UPDATELOANOFFICER_LOAN', + 'UPDATELOANOFFICER_LOAN_CHECKER', + 'WAIVEINTERESTPORTION_LOAN', + 'WAIVEINTERESTPORTION_LOAN_CHECKER', + 'WITHDRAW_LOAN', + 'WITHDRAW_LOAN_CHECKER', + 'WRITEOFF_LOAN', + 'WRITEOFF_LOAN_CHECKER' + ); + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'LEDGER' + + SELECT COUNT(*) FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'LEDGER') + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT (SELECT id FROM m_role WHERE name = 'LEDGER'), p.id + FROM m_permission p + WHERE p.code IN ( + 'MENU_GENERAL_LEDGER', + 'MENU_SUB_LEDGER' + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_backfill_required_charges.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_backfill_required_charges.xml new file mode 100644 index 000000000..a78497b7b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_backfill_required_charges.xml @@ -0,0 +1,106 @@ + + + + + + + + + SELECT COUNT(*) FROM m_charge WHERE name = 'PROCESSING FEES' + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_charge WHERE name = 'CHQ BOOK CHARGES' + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_charge WHERE name = 'entry fee' + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_facility_charge_mapping_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_facility_charge_mapping_permissions.xml new file mode 100644 index 000000000..26768ab7a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0499_add_facility_charge_mapping_permissions.xml @@ -0,0 +1,55 @@ + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_FACILITYCHARGEMAPPING' + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_reset_facility_charge_mappings_to_unmapped.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_reset_facility_charge_mappings_to_unmapped.xml new file mode 100644 index 000000000..630271816 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0500_reset_facility_charge_mappings_to_unmapped.xml @@ -0,0 +1,40 @@ + + + + + + + + UPDATE m_facility_charge_mapping SET charge_id = NULL, is_active = false WHERE charge_id IS NOT NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_enforce_inr_only_organisation_currency.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_enforce_inr_only_organisation_currency.xml new file mode 100644 index 000000000..07a186f47 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0501_enforce_inr_only_organisation_currency.xml @@ -0,0 +1,50 @@ + + + + + + + + SELECT COUNT(*) FROM m_organisation_currency WHERE code = 'INR' + + + INSERT INTO m_organisation_currency (code, decimal_places, name, display_symbol, internationalized_name_code) + VALUES ('INR', 2, 'Indian Rupee', convert_from(decode('e282b9', 'hex'), 'UTF8'), 'currency.INR'); + + + + + + DELETE FROM m_organisation_currency WHERE code <> 'INR'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml new file mode 100644 index 000000000..c1c78f4cb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml @@ -0,0 +1,196 @@ + + + + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'BANKS CURRENT ACCOUNT', '3', false, true, 1, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '3'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'SHORT TERM LOAN', '4', false, true, 1, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '4'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'MEDIUM TERM LOAN', '5', false, true, 1, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '5'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'LONG TERM LOAN', '6', false, true, 1, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '6'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'DEPOSITE ACCOUNT', '8', false, true, 2, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '8'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'OTHER LIABILITY', '9', false, true, 2, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '9'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'SHARE CAPITAL', '7', false, true, 3, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '7'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'INCOME HEAD', '10', false, true, 4, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '10'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'EXPENSES', '11', false, true, 5, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '11'); + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy, description) + SELECT 'Cash at Vault', '1', false, true, 1, 1, '.', 'Test' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '1'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Cash at Tellers', '2', false, true, 1, 1, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '2'); + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage) + SELECT 'Liability Transfer Suspense', '200800', false, true, 2, 1 WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200800'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, description) + SELECT 'Share Sundry', 'SHASUN001', false, true, 2, 1, 'Holding account for share amounts pending activation' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = 'SHASUN001'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, description) + SELECT 'Escheat Liability Account', '200900', false, true, 2, 1, 'Holds funds from accounts escheated to the regulator after prolonged dormancy.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200900'); + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'IDBI BANK', '3.1', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '3') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '3.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'YES BANK', '3.2', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '3') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '3.2'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'LOAN AGAINST DEPOSITE', '4.1', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '4') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '4.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'WARE HOUSE RECEIPT', '4.2', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '4') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '4.2'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'CASH CREDIT', '4.3', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '4') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '4.3'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'LOAN AGAINST PROPERTY', '5.1', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '5') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '5.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'PERSONAL LOAN', '5.3', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '5') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '5.3'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'HOUSE LOAN', '6.1', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '6') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '6.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'SAVING INDIVIDUAL', '200200', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '8') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200200'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'CURRENT ACCOUNT', '300300', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '8') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300300'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'FIXED DEPOSITE', '400400', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '8') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400400'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'RD ACCOUNT', '500500', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '9') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500500'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'INTEREST PAYABLE', '9.2', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '9') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '9.2'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id, description) + SELECT 'Sponsor DD Payable - SBI', '9.3', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '9'), + 'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '9.3'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id, description) + SELECT 'DD Cash Received - Pending Approval', '9.4', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '9'), + 'Interim liability for cash physically received from a walk-in DD purchaser at submission time, pending manager approval of the issuance request. Cleared into Sponsor DD Payable + Charges Income on approval, or reversed back to Cash at Tellers on rejection.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '9.4'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'LINK SHARE', '7.1', false, true, 3, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '7') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '7.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'SHARE', '7.2', false, true, 3, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '7') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '7.2'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'INCOME FROM LOAN', '10.1', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '10') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '10.1'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'INCOME FROM ALL', '10.2', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '10') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '10.2'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id, description) + SELECT 'GST Payable on DD Charges', '9.5', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '10'), + 'GST collected from customers/purchasers on Sponsor DD issuance charges, held pending remittance to the government - not bank income.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '9.5'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id, description) + SELECT 'Share Entry', 'SE8978', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '10'), 'Done' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = 'SE8978'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'EXPENSES FOR ALL', '11.1', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '11') + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '11.1'); + + + + + + + UPDATE acc_gl_account SET hierarchy = '.' || id || '.' + WHERE hierarchy IS NULL AND gl_code IN ( + '3.1', '3.2', '4.1', '4.2', '4.3', '5.1', '5.3', '6.1', '200200', '300300', '400400', + '500500', '9.2', '9.3', '9.4', '7.1', '7.2', '10.1', '10.2', '9.5', 'SE8978', '11.1' + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_standardize_gl_account_codes.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_standardize_gl_account_codes.xml new file mode 100644 index 000000000..c9b77d8be --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0503_standardize_gl_account_codes.xml @@ -0,0 +1,89 @@ + + + + + + + + UPDATE acc_gl_account SET gl_code = '100101' WHERE name = 'Cash at Vault'; + UPDATE acc_gl_account SET gl_code = '100102' WHERE name = 'Cash at Tellers'; + UPDATE acc_gl_account SET gl_code = '100200' WHERE name = 'BANKS CURRENT ACCOUNT'; + UPDATE acc_gl_account SET gl_code = '100201' WHERE name = 'IDBI BANK'; + UPDATE acc_gl_account SET gl_code = '100202' WHERE name = 'YES BANK'; + UPDATE acc_gl_account SET gl_code = '100300' WHERE name = 'SHORT TERM LOAN'; + UPDATE acc_gl_account SET gl_code = '100301' WHERE name = 'LOAN AGAINST DEPOSITE'; + UPDATE acc_gl_account SET gl_code = '100302' WHERE name = 'WARE HOUSE RECEIPT'; + UPDATE acc_gl_account SET gl_code = '100303' WHERE name = 'CASH CREDIT'; + UPDATE acc_gl_account SET gl_code = '100400' WHERE name = 'MEDIUM TERM LOAN'; + UPDATE acc_gl_account SET gl_code = '100401' WHERE name = 'LOAN AGAINST PROPERTY'; + UPDATE acc_gl_account SET gl_code = '100402' WHERE name = 'PERSONAL LOAN'; + UPDATE acc_gl_account SET gl_code = '100500' WHERE name = 'LONG TERM LOAN'; + UPDATE acc_gl_account SET gl_code = '100501' WHERE name = 'HOUSE LOAN'; + + UPDATE acc_gl_account SET gl_code = '200100' WHERE TRIM(name) = 'DEPOSITE ACCOUNT'; + UPDATE acc_gl_account SET gl_code = '200101' WHERE name = 'SAVING INDIVIDUAL'; + UPDATE acc_gl_account SET gl_code = '200102' WHERE name = 'CURRENT ACCOUNT'; + UPDATE acc_gl_account SET gl_code = '200103' WHERE name = 'FIXED DEPOSITE'; + UPDATE acc_gl_account SET gl_code = '200200' WHERE name = 'OTHER LIABILITY'; + UPDATE acc_gl_account SET gl_code = '200201' WHERE name = 'RD ACCOUNT'; + UPDATE acc_gl_account SET gl_code = '200202' WHERE name = 'INTEREST PAYABLE'; + UPDATE acc_gl_account SET gl_code = '200203' WHERE name = 'Sponsor DD Payable - SBI'; + UPDATE acc_gl_account SET gl_code = '200204' WHERE name = 'DD Cash Received - Pending Approval'; + UPDATE acc_gl_account SET gl_code = '200901' WHERE name = 'Liability Transfer Suspense'; + UPDATE acc_gl_account SET gl_code = '200902' WHERE name = 'Share Sundry'; + UPDATE acc_gl_account SET gl_code = '200903' WHERE name = 'Dividend Payable'; + UPDATE acc_gl_account SET gl_code = '200904' WHERE name = 'Escheat Liability Account'; + UPDATE acc_gl_account SET gl_code = '200905' WHERE name = 'Education Fund Payable'; + UPDATE acc_gl_account SET gl_code = '200906' WHERE name = 'Dividend Paid'; + + UPDATE acc_gl_account SET gl_code = '300100' WHERE name = 'SHARE CAPITAL'; + UPDATE acc_gl_account SET gl_code = '300101' WHERE name = 'LINK SHARE'; + UPDATE acc_gl_account SET gl_code = '300102' WHERE name = 'SHARE'; + UPDATE acc_gl_account SET gl_code = '300901' WHERE name = 'Reserve for Dividend'; + UPDATE acc_gl_account SET gl_code = '300902' WHERE name = 'Statutory Reserve Fund'; + UPDATE acc_gl_account SET gl_code = '300903' WHERE name = 'Bank Profit (Non-Distributable)'; + + UPDATE acc_gl_account SET gl_code = '400100' WHERE name = 'INCOME HEAD'; + UPDATE acc_gl_account SET gl_code = '400101' WHERE name = 'INCOME FROM LOAN'; + UPDATE acc_gl_account SET gl_code = '400102' WHERE name = 'INCOME FROM ALL'; + UPDATE acc_gl_account SET gl_code = '400103' WHERE name = 'DD Issuance Charges Income'; + UPDATE acc_gl_account SET gl_code = '400104' WHERE name = 'GST Payable on DD Charges'; + UPDATE acc_gl_account SET gl_code = '400105' WHERE name = 'Share Entry'; + UPDATE acc_gl_account SET gl_code = '400901' WHERE name = 'Dividend Forfeited Income'; + + UPDATE acc_gl_account SET gl_code = '500100' WHERE name = 'EXPENSES'; + UPDATE acc_gl_account SET gl_code = '500101' WHERE name = 'EXPENSES FOR ALL'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_priority_gl_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_priority_gl_accounts.xml new file mode 100644 index 000000000..ec7256371 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0504_add_priority_gl_accounts.xml @@ -0,0 +1,200 @@ + + + + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Interest & Other Receivables', '100600', false, true, 1, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100600'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Interest Payable on Deposits', '200300', false, true, 2, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200300'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Statutory Dues Payable', '200400', false, true, 2, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200400'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Clearing & Settlement Suspense', '200500', false, true, 2, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200500'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Facility & Service Charges Income', '400200', false, true, 4, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400200'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Interest Income on Loans', '400300', false, true, 4, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400300'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Interest Expense on Deposits', '500200', false, true, 5, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500200'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Staff Cost', '500300', false, true, 5, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500300'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Administrative & Operating Expenses', '500400', false, true, 5, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500400'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Provisions & Write-offs', '500500', false, true, 5, 2, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500500'); + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Receivable on Loans', '100601', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '100600') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100601'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'TDS Receivable (Advance Tax)', '100602', false, true, 1, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '100600') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100602'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Payable - Savings Account', '200301', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200301'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Payable - Fixed Deposit', '200302', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200302'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Payable - RD Account', '200303', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200303'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'TDS Payable', '200401', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200401'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'GST Payable', '200402', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200402'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'NEFT Inward Suspense', '200501', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200501'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'NEFT Outward Suspense', '200502', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200502'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'RTGS Suspense', '200503', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200503'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Cheque Clearing Suspense', '200504', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200504'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'ATM Settlement Suspense', '200505', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200505'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'UPI Settlement Suspense', '200506', false, true, 2, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '200500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200506'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from ATM Facility Charges', '400201', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400201'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from ATM Withdrawal Charges', '400202', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400202'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from UPI Facility Charges', '400203', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400203'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from POS Transaction Charges', '400204', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400204'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Cheque Book Charges', '400205', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400205'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from IMPS Facility Charges', '400206', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400206'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from SMS Alert Charges', '400207', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400207'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from BBPS Facility Charges', '400208', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400208'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Insurance Cover Charges', '400209', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400209'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from BEPG Facility Charges', '400210', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400210'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from NEFT Facility Charges', '400211', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400211'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from RTGS Facility Charges', '400212', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400212'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Inter-Bank Cap Charges', '400213', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400213'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Withdrawal Facility Charges', '400214', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400214'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Deposit KYC Charges', '400215', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400215'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Income from Virtual Debit Card Charges', '400216', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400216'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Income - Short Term Loan', '400301', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400301'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Income - Medium Term Loan', '400302', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400302'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Income - Long Term Loan', '400303', false, true, 4, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '400300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400303'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Paid - Savings Account', '500201', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500201'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Paid - Fixed Deposit', '500202', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500202'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Interest Paid - RD Account', '500203', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500200') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500203'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Salaries & Wages', '500301', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500301'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Staff Welfare Expenses', '500302', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500302'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Provident Fund Contribution', '500303', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500300') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500303'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Rent, Rates & Taxes', '500401', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500401'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Electricity & Water Charges', '500402', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500402'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Printing & Stationery', '500403', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500403'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Telephone & Internet Expenses', '500404', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500404'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Audit Fees', '500405', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500405'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Legal & Professional Charges', '500406', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500406'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Insurance Premium', '500407', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500407'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Repairs & Maintenance', '500408', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500408'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Depreciation', '500409', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500400') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500409'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Provision for NPA', '500501', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500501'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Provision for Standard Assets', '500502', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500502'); + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, parent_id) + SELECT 'Bad Debts Written Off', '500503', false, true, 5, 1, (SELECT id FROM acc_gl_account WHERE gl_code = '500500') WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '500503'); + + + + + + + UPDATE acc_gl_account SET hierarchy = '.' || id || '.' + WHERE hierarchy IS NULL AND gl_code IN ( + '100601','100602', + '200301','200302','200303','200401','200402', + '200501','200502','200503','200504','200505','200506', + '400201','400202','400203','400204','400205','400206','400207','400208','400209','400210', + '400211','400212','400213','400214','400215','400216', + '400301','400302','400303', + '500201','500202','500203', + '500301','500302','500303', + '500401','500402','500403','500404','500405','500406','500407','500408','500409', + '500501','500502','500503' + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_backfill_missing_codes_and_code_values.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_backfill_missing_codes_and_code_values.xml new file mode 100644 index 000000000..65e8eb4ab --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0505_backfill_missing_codes_and_code_values.xml @@ -0,0 +1,674 @@ + + + + + + + INSERT INTO m_code (code_name, is_system_defined) SELECT 'ADDRESS_TYPE', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ADDRESS_TYPE'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'AssetAccountTags', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'AssetAccountTags'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'buydown_fee_transaction_classification', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'buydown_fee_transaction_classification'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'capitalized_income_transaction_classification', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'capitalized_income_transaction_classification'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'CenterClosureReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'CenterClosureReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ChargeOffReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ChargeOffReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ClientClosureReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ClientClosureReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ClientRejectReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ClientRejectReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ClientSubStatus', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ClientSubStatus'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ClientType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ClientType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ClientWithdrawReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ClientWithdrawReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Constitution', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Constitution'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Constitution2', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Constitution2'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'COUNTRY', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'COUNTRY'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'CreditTransactionFreezeReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'CreditTransactionFreezeReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'CurrentProductAccountType', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'CurrentProductAccountType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'CurrentProductCustomerSegment', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'CurrentProductCustomerSegment'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Customer Documents', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Customer Documents'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Customer Identifier', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Customer Identifier'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'DebitTransactionFreezeReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'DebitTransactionFreezeReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'DegreeQualification', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'DegreeQualification'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'DirectorDesignation', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'DirectorDesignation'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'EducationLevel', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'EducationLevel'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Entity to Entity Access Types', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Entity to Entity Access Types'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'EquityAccountTags', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'EquityAccountTags'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ExpenseAccountTags', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ExpenseAccountTags'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'FixedDepositProductAccountType', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'FixedDepositProductAccountType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'FixedDepositProductCustomerSegment', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'FixedDepositProductCustomerSegment'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'FORM_60_PURPOSE', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'FORM_60_PURPOSE'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Gender', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Gender'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'GroupClosureReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GroupClosureReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'GROUPROLE', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GROUPROLE'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'GuarantorRelationship', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GuarantorRelationship'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'IncomeAccountTags', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'IncomeAccountTags'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'InterestCalculationTypeOption', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'InterestCalculationTypeOption'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'KYCAddressProofType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'KYCAddressProofType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'KYCIdProofType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'KYCIdProofType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LiabilityAccountTags', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LiabilityAccountTags'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LoanCollateral', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LoanCollateral'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LoanOriginationChannelType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LoanOriginationChannelType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LoanOriginatorType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LoanOriginatorType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LoanPurpose', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LoanPurpose'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'LoanRescheduleReason', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'LoanRescheduleReason'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Main Business Line', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Main Business Line'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'MARITAL STATUS', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'MARITAL STATUS'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'MaritalStatus', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'MaritalStatus'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Occupation', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Occupation'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'OccupationCategory', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'OccupationCategory'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'PaymentType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PaymentType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'PeriodFrequency', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PeriodFrequency'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'PROFESSION', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PROFESSION'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'PwdCategory', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PwdCategory'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ReAgeReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ReAgeReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'ReAmortizationReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'ReAmortizationReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'RecurringDepositProductAccountType', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'RecurringDepositProductAccountType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'RecurringDepositProductCustomerSegment', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'RecurringDepositProductCustomerSegment'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'RELATIONSHIP', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'RELATIONSHIP'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Religion', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Religion'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'Salutation', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'Salutation'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SavingsAccountBlockReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsAccountBlockReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SavingsProductAccountType', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductAccountType'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SavingsProductCustomerSegment', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductCustomerSegment'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SavingsTransactionFreezeReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsTransactionFreezeReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SocialCategory', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SocialCategory'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'STATE', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'STATE'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'SubCaste', false WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SubCaste'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'WriteOffReasons', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'WriteOffReasons'); +INSERT INTO m_code (code_name, is_system_defined) SELECT 'YesNo', true WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'YesNo'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Correspondence', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'ADDRESS_TYPE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ADDRESS_TYPE' AND v.code_value = 'Correspondence'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Current / Residential', 'Current Address', 2, NULL, true, false FROM m_code WHERE code_name = 'ADDRESS_TYPE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ADDRESS_TYPE' AND v.code_value = 'Current / Residential'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Office / Work', 'Office Address', 3, NULL, true, false FROM m_code WHERE code_name = 'ADDRESS_TYPE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ADDRESS_TYPE' AND v.code_value = 'Office / Work'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Permanent Address', 'Permanent Address', 1, NULL, true, false FROM m_code WHERE code_name = 'ADDRESS_TYPE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ADDRESS_TYPE' AND v.code_value = 'Permanent Address'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CURRENT_ASSET', 'Current Asset', 1, NULL, true, false FROM m_code WHERE code_name = 'AssetAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'AssetAccountTags' AND v.code_value = 'CURRENT_ASSET'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FIXED_ASSET', 'Fixed Asset', 2, NULL, true, false FROM m_code WHERE code_name = 'AssetAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'AssetAccountTags' AND v.code_value = 'FIXED_ASSET'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUYDOWN_ADJUSTMENT', 'Buydown Adjustment Entry', 3, NULL, true, false FROM m_code WHERE code_name = 'buydown_fee_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'buydown_fee_transaction_classification' AND v.code_value = 'BUYDOWN_ADJUSTMENT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUYDOWN_INTEREST_SUBSIDY', 'Buydown Interest Subsidy', 2, NULL, true, false FROM m_code WHERE code_name = 'buydown_fee_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'buydown_fee_transaction_classification' AND v.code_value = 'BUYDOWN_INTEREST_SUBSIDY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUYDOWN_PROCESSING_FEE', 'Buydown Processing Fee', 1, NULL, true, false FROM m_code WHERE code_name = 'buydown_fee_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'buydown_fee_transaction_classification' AND v.code_value = 'BUYDOWN_PROCESSING_FEE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CAPITALIZED_FEES', 'Capitalized Fees', 3, NULL, true, false FROM m_code WHERE code_name = 'capitalized_income_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'capitalized_income_transaction_classification' AND v.code_value = 'CAPITALIZED_FEES'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CAPITALIZED_INTEREST', 'Capitalized Interest Income', 1, NULL, true, false FROM m_code WHERE code_name = 'capitalized_income_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'capitalized_income_transaction_classification' AND v.code_value = 'CAPITALIZED_INTEREST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CAPITALIZED_PENAL_INTEREST', 'Capitalized Penal Interest', 2, NULL, true, false FROM m_code WHERE code_name = 'capitalized_income_transaction_classification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'capitalized_income_transaction_classification' AND v.code_value = 'CAPITALIZED_PENAL_INTEREST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CENTER_INACTIVE', 'Center Inactive', 1, NULL, true, false FROM m_code WHERE code_name = 'CenterClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CenterClosureReason' AND v.code_value = 'CENTER_INACTIVE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MERGED', 'Merged with another center', 2, NULL, true, false FROM m_code WHERE code_name = 'CenterClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CenterClosureReason' AND v.code_value = 'MERGED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DECEASED', 'Borrower Deceased', 4, NULL, true, false FROM m_code WHERE code_name = 'ChargeOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ChargeOffReasons' AND v.code_value = 'DECEASED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FRAUD', 'Fraudulent Account Charge Off', 3, NULL, true, false FROM m_code WHERE code_name = 'ChargeOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ChargeOffReasons' AND v.code_value = 'FRAUD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INSOLVENCY', 'Insolvency / Bankruptcy', 5, NULL, true, false FROM m_code WHERE code_name = 'ChargeOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ChargeOffReasons' AND v.code_value = 'INSOLVENCY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SETTLED', 'Account Settled', 2, NULL, true, false FROM m_code WHERE code_name = 'ChargeOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ChargeOffReasons' AND v.code_value = 'SETTLED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'WRITTEN_OFF', 'Account Written Off', 1, NULL, true, false FROM m_code WHERE code_name = 'ChargeOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ChargeOffReasons' AND v.code_value = 'WRITTEN_OFF'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CUSTOMER_REQUEST', 'Closed on customer request', 1, NULL, true, false FROM m_code WHERE code_name = 'ClientClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientClosureReason' AND v.code_value = 'CUSTOMER_REQUEST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DECEASED', 'Customer Deceased', 2, NULL, true, false FROM m_code WHERE code_name = 'ClientClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientClosureReason' AND v.code_value = 'DECEASED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DORMANT', 'Dormant Closure', 4, NULL, true, false FROM m_code WHERE code_name = 'ClientClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientClosureReason' AND v.code_value = 'DORMANT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FRAUD', 'Fraudulent Account', 3, NULL, true, false FROM m_code WHERE code_name = 'ClientClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientClosureReason' AND v.code_value = 'FRAUD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DOCUMENT_INCOMPLETE', 'Incomplete Documents', 3, NULL, true, false FROM m_code WHERE code_name = 'ClientRejectReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientRejectReason' AND v.code_value = 'DOCUMENT_INCOMPLETE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC_FAILED', 'KYC Verification Failed', 1, NULL, true, false FROM m_code WHERE code_name = 'ClientRejectReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientRejectReason' AND v.code_value = 'KYC_FAILED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LOW_CIBIL', 'Low Credit Score', 2, NULL, true, false FROM m_code WHERE code_name = 'ClientRejectReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientRejectReason' AND v.code_value = 'LOW_CIBIL'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ACTIVE', 'Active Customer', 1, NULL, true, false FROM m_code WHERE code_name = 'ClientSubStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientSubStatus' AND v.code_value = 'ACTIVE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BLOCKED', 'Blocked Account', 4, NULL, true, false FROM m_code WHERE code_name = 'ClientSubStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientSubStatus' AND v.code_value = 'BLOCKED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DORMANT', 'Dormant Status', 3, NULL, true, false FROM m_code WHERE code_name = 'ClientSubStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientSubStatus' AND v.code_value = 'DORMANT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INACTIVE', 'Inactive Customer', 2, NULL, true, false FROM m_code WHERE code_name = 'ClientSubStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientSubStatus' AND v.code_value = 'INACTIVE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CORPORATE', 'Corporate / Large Business Entity', 15, 10, true, false FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'CORPORATE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GOVERNMENT', 'Government / Semi-Government Entity', 12, 10, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'GOVERNMENT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INSTITUTIONAL', 'Institutional Account (School, College, etc.)', 13, 8, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'INSTITUTIONAL'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LLP', 'Limited Liability Partnership', 7, 9, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'LLP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MINOR', 'Minor Account (Operated by Guardian)', 3, 8, true, false FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'MINOR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MSME', 'Micro, Small & Medium Enterprise', 14, 9, true, false FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'MSME'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NRI CUSTOMER', 'Non-Resident Individual Customer', 2, 9, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'NRI CUSTOMER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PARTNERSHIP', 'Partnership Firm', 6, 9, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'PARTNERSHIP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PRIVATE LIMITED', 'Private Limited Company', 8, 10, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'PRIVATE LIMITED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PROPRIETORSHIP', 'Proprietorship Firm', 5, 9, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'PROPRIETORSHIP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PUBLIC LIMITED', 'Public Limited Company', 9, 10, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'PUBLIC LIMITED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RESIDENT INDIVIDUAL', 'Resident Individual Customer', 1, 10, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'RESIDENT INDIVIDUAL'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SENIOR CITIZEN', 'Senior Citizen Customer', 4, 7, true, false FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'SENIOR CITIZEN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SOCIETY', 'Society / Association / Club', 11, 8, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'SOCIETY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TRUST', 'Trust Account', 10, 8, true, true FROM m_code WHERE code_name = 'ClientType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientType' AND v.code_value = 'TRUST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NON_COMPLIANCE', 'Compliance Failure', 2, NULL, true, false FROM m_code WHERE code_name = 'ClientWithdrawReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientWithdrawReason' AND v.code_value = 'NON_COMPLIANCE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'VOLUNTARY', 'Voluntary Withdrawal', 1, NULL, true, false FROM m_code WHERE code_name = 'ClientWithdrawReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ClientWithdrawReason' AND v.code_value = 'VOLUNTARY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'HUF (Hindu Undivided Family)', 'Hindu Undivided Family', 1, NULL, true, false FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'HUF (Hindu Undivided Family)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LLP', 'Limited Liability Partnership', 14, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'LLP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PARTNERSHIP', 'Partnership firm', 11, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'PARTNERSHIP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PRIVATE LIMITED COMPANY', 'Private limited company', 12, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'PRIVATE LIMITED COMPANY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PROPRIETOR SHIP', 'Sole proprietorship business', 10, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'PROPRIETOR SHIP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PUBLIC LIMITED COMPANY', 'Public limited company', 13, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'PUBLIC LIMITED COMPANY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SOCIETY', 'Registered society / association', 16, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'SOCIETY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TRUST', 'Registered trust entity', 15, NULL, true, true FROM m_code WHERE code_name = 'Constitution' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Constitution' AND v.code_value = 'TRUST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INDIA', 'Republic of India', 1, NULL, true, false FROM m_code WHERE code_name = 'COUNTRY' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'COUNTRY' AND v.code_value = 'INDIA'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'COMPLIANCE_HOLD', 'Compliance Hold', 1, NULL, true, false FROM m_code WHERE code_name = 'CreditTransactionFreezeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CreditTransactionFreezeReasons' AND v.code_value = 'COMPLIANCE_HOLD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Basic Account', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'CurrentProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CurrentProductAccountType' AND v.code_value = 'Basic Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Premium Current Account', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'CurrentProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CurrentProductAccountType' AND v.code_value = 'Premium Current Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Standard Account', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'CurrentProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CurrentProductAccountType' AND v.code_value = 'Standard Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Individual', 'Individual', 1, NULL, true, false FROM m_code WHERE code_name = 'CurrentProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CurrentProductCustomerSegment' AND v.code_value = 'Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Non Individual', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'CurrentProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'CurrentProductCustomerSegment' AND v.code_value = 'Non Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AADHAR', 'Aadhar Card', 1, NULL, true, false FROM m_code WHERE code_name = 'Customer Documents' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Documents' AND v.code_value = 'AADHAR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN', 'PAN Card', 2, NULL, true, false FROM m_code WHERE code_name = 'Customer Documents' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Documents' AND v.code_value = 'PAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PASSPORT', 'Passport', 4, NULL, true, false FROM m_code WHERE code_name = 'Customer Documents' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Documents' AND v.code_value = 'PASSPORT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'VOTER_ID', 'Voter ID Card', 3, NULL, true, false FROM m_code WHERE code_name = 'Customer Documents' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Documents' AND v.code_value = 'VOTER_ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AADHAAR', 'Aadhaar Card', 1, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'AADHAAR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Any Other ID', 'Any Other ID', 18, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'Any Other ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CENTRAL STATE ID', 'Central/State Govt Employee Card', 11, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'CENTRAL STATE ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'COMPANY EMP ID', 'Company Employee ID', 13, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'COMPANY EMP ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DEFENCE ID', 'Defence / Armed Forces ID', 7, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'DEFENCE ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Drivers License', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'Drivers License'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GOVT EMP ID', 'Government Employee ID', 6, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'GOVT EMP ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KISAN PASSBOOK', 'Kisan Photo Passbook', 10, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'KISAN PASSBOOK'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MUNICIPAL ID', 'Municipal ID Card', 17, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'MUNICIPAL ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NPR', 'NPR Card', 4, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'NPR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NREGA', 'NREGA Job Card', 3, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'NREGA'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OCI', 'OCI Card', 14, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'OCI'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN', 'PAN Card', 2, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'PAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Passport', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'Passport'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PIO', 'PIO Card', 15, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'PIO'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PPO', 'Pension Payment Order (PPO)', 8, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'PPO'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RATION', 'Ration Card', 5, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'RATION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RESIDENCE CERT', 'Residence Certificate', 16, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'RESIDENCE CERT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SENIOR CITIZEN', 'Senior Citizen Card', 9, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'SENIOR CITIZEN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'STUDENT ID', 'Student ID', 12, 0, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'STUDENT ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Voter ID', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'Customer Identifier' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Customer Identifier' AND v.code_value = 'Voter ID'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INSUFFICIENT_FUNDS', 'Insufficient Balance', 1, NULL, true, false FROM m_code WHERE code_name = 'DebitTransactionFreezeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DebitTransactionFreezeReasons' AND v.code_value = 'INSUFFICIENT_FUNDS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, '10th / SSC / Matriculation', 'School', 2, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = '10th / SSC / Matriculation'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, '12th / HSC / Intermediate', 'School', 3, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = '12th / HSC / Intermediate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.A. (Bachelor of Arts)', 'Undergraduate', 6, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.A. (Bachelor of Arts)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Arch', 'Undergraduate', 19, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Arch'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Com. (Bachelor of Commerce)', 'Undergraduate', 8, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Com. (Bachelor of Commerce)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Ed. (Education)', 'Undergraduate', 12, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Ed. (Education)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Pharm', 'Undergraduate', 18, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Pharm'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Sc. (Bachelor of Science)', 'Undergraduate', 7, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Sc. (Bachelor of Science)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Sc. Nursing', 'Undergraduate', 22, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Sc. Nursing'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'B.Tech / B.E. (Engineering)', 'Undergraduate', 11, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'B.Tech / B.E. (Engineering)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BAMS', 'Undergraduate', 16, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BAMS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BBA (Business Administration)', 'Undergraduate', 9, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BBA (Business Administration)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BCA (Computer Applications)', 'Undergraduate', 10, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BCA (Computer Applications)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BDS', 'Undergraduate', 15, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BDS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Below 10th', 'School', 1, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Below 10th'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BFA (Fine Arts)', 'Undergraduate', 20, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BFA (Fine Arts)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BHM (Hotel Management)', 'Undergraduate', 21, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BHM (Hotel Management)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BHMS', 'Undergraduate', 17, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'BHMS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CA (Chartered Accountant)', 'Doctorate / Professional', 43, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'CA (Chartered Accountant)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CFA', 'Doctorate / Professional', 46, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'CFA'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CMA (Cost & Mgmt Accountant)', 'Doctorate / Professional', 45, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'CMA (Cost & Mgmt Accountant)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CS (Company Secretary)', 'Doctorate / Professional', 44, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'CS (Company Secretary)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'D.Litt.', 'Doctorate / Professional', 41, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'D.Litt.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'D.Sc.', 'Doctorate / Professional', 42, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'D.Sc.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma', 'School', 4, NULL, false, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Automobile Engineering', 'Diploma', 53, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Automobile Engineering'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Civil Engineering', 'Diploma', 49, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Civil Engineering'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Computer Science / IT', 'Diploma', 52, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Computer Science / IT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Electrical Engineering', 'Diploma', 50, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Electrical Engineering'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Electronics & Communication', 'Diploma', 51, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Electronics & Communication'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma in Mechanical Engineering', 'Diploma', 48, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Diploma in Mechanical Engineering'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ITI', 'School', 5, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'ITI'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ITI Certificate', 'Diploma', 54, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'ITI Certificate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LLB (Laws)', 'Undergraduate', 13, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'LLB (Laws)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LLM (Laws)', 'Postgraduate', 31, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'LLM (Laws)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.A. (Master of Arts)', 'Postgraduate', 24, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.A. (Master of Arts)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Arch', 'Postgraduate', 36, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Arch'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Com. (Master of Commerce)', 'Postgraduate', 26, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Com. (Master of Commerce)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Ed. (Education)', 'Postgraduate', 30, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Ed. (Education)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Pharm', 'Postgraduate', 35, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Pharm'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Sc. (Master of Science)', 'Postgraduate', 25, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Sc. (Master of Science)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'M.Tech / M.E. (Engineering)', 'Postgraduate', 29, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'M.Tech / M.E. (Engineering)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MBA (Business Administration)', 'Postgraduate', 27, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MBA (Business Administration)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MBBS', 'Undergraduate', 14, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MBBS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MCA (Computer Applications)', 'Postgraduate', 28, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MCA (Computer Applications)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MD (Medicine)', 'Postgraduate', 32, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MD (Medicine)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MDS', 'Postgraduate', 34, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MDS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MFA (Fine Arts)', 'Postgraduate', 37, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MFA (Fine Arts)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MS (Surgery)', 'Postgraduate', 33, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MS (Surgery)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MSW (Social Work)', 'Postgraduate', 38, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'MSW (Social Work)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other Diploma', 'Diploma', 56, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Other Diploma'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other PG', 'Postgraduate', 39, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Other PG'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other Professional', 'Doctorate / Professional', 47, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Other Professional'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other UG', 'Undergraduate', 23, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Other UG'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Ph.D. (Doctor of Philosophy)', 'Doctorate / Professional', 40, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Ph.D. (Doctor of Philosophy)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Polytechnic Diploma (Other)', 'Diploma', 55, NULL, true, false FROM m_code WHERE code_name = 'DegreeQualification' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DegreeQualification' AND v.code_value = 'Polytechnic Diploma (Other)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chairman', NULL, 1, 1, true, true FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Chairman'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Director', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Director'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Principal Director', NULL, 6, 2, true, false FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Principal Director'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Secretary', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Secretary'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Treasurer', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Treasurer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Vice Chairman', NULL, 2, 1, true, true FROM m_code WHERE code_name = 'DirectorDesignation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'DirectorDesignation' AND v.code_value = 'Vice Chairman'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Diploma', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'Diploma'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Doctorate / Professional', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'Doctorate / Professional'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Graduate', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'Graduate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Postgraduate', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'Postgraduate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'School', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'School'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Undergraduate', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'EducationLevel' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EducationLevel' AND v.code_value = 'Undergraduate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Office Access to Fees/Charges', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'Entity to Entity Access Types' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Entity to Entity Access Types' AND v.code_value = 'Office Access to Fees/Charges'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Office Access to Loan Products', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'Entity to Entity Access Types' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Entity to Entity Access Types' AND v.code_value = 'Office Access to Loan Products'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Office Access to Savings Products', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'Entity to Entity Access Types' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Entity to Entity Access Types' AND v.code_value = 'Office Access to Savings Products'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OWNER_EQUITY', 'Owner Equity', 1, NULL, true, false FROM m_code WHERE code_name = 'EquityAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EquityAccountTags' AND v.code_value = 'OWNER_EQUITY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RETAINED_EARNINGS', 'Retained Earnings', 2, NULL, true, false FROM m_code WHERE code_name = 'EquityAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'EquityAccountTags' AND v.code_value = 'RETAINED_EARNINGS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INTEREST_EXPENSE', 'Interest Expense', 2, NULL, true, false FROM m_code WHERE code_name = 'ExpenseAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ExpenseAccountTags' AND v.code_value = 'INTEREST_EXPENSE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OPERATING_EXPENSE', 'Operating Expense', 1, NULL, true, false FROM m_code WHERE code_name = 'ExpenseAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ExpenseAccountTags' AND v.code_value = 'OPERATING_EXPENSE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Fd Account', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'FixedDepositProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FixedDepositProductAccountType' AND v.code_value = 'Fd Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Individual', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'FixedDepositProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FixedDepositProductCustomerSegment' AND v.code_value = 'Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Non-Individual', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'FixedDepositProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FixedDepositProductCustomerSegment' AND v.code_value = 'Non-Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Agricultural Gold Loan', NULL, 26, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Agricultural Gold Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Agricultural Loan', NULL, 15, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Agricultural Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Business Loan', NULL, 14, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Business Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Cash Deposit in Bank', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Cash Deposit in Bank'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Cash Withdrawal from Bank', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Cash Withdrawal from Bank'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Credit Card Application', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Credit Card Application'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Debit Card Application', NULL, 8, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Debit Card Application'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Digital Gold Purchase', NULL, 47, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Digital Gold Purchase'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Education Loan', NULL, 13, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Education Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Fixed Deposit / Term Deposit', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Fixed Deposit / Term Deposit'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Foreign Travel Payment', NULL, 34, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Foreign Travel Payment'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Deposit Scheme', NULL, 49, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Deposit Scheme'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold ETF Investment', NULL, 46, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold ETF Investment'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Investment Purchase', NULL, 44, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Investment Purchase'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Closure / Settlement', NULL, 19, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Closure / Settlement'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Interest Payment', NULL, 20, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Interest Payment'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - New Loan', NULL, 16, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - New Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Partial Payment', NULL, 21, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Partial Payment'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Pledge of Gold Jewellery', NULL, 22, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Pledge of Gold Jewellery'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Release of Gold Jewellery', NULL, 23, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Release of Gold Jewellery'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Renewal', NULL, 17, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Renewal'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Loan - Top Up', NULL, 18, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Loan - Top Up'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gold Savings Scheme', NULL, 48, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Gold Savings Scheme'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'High Value Financial Transaction', NULL, 54, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'High Value Financial Transaction'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Home Loan', NULL, 11, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Home Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Hotel or Restaurant Payment above prescribed limit', NULL, 36, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Hotel or Restaurant Payment above prescribed limit'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Investment in Bonds / Debentures', NULL, 50, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Investment in Bonds / Debentures'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Large Cash Transaction', NULL, 53, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Large Cash Transaction'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Life Insurance Premium Payment', NULL, 33, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Life Insurance Premium Payment'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Loan Against Gold Coins', NULL, 24, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Loan Against Gold Coins'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Loan Against Gold Ornaments', NULL, 25, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Loan Against Gold Ornaments'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Loan Application', NULL, 9, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Loan Application'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Opening Bank Account', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Opening Bank Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Opening Demat Account', NULL, 32, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Opening Demat Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Opening Post Office Savings Account', NULL, 51, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Opening Post Office Savings Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Opening Trading Account', NULL, 52, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Opening Trading Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other Financial Transaction', NULL, 55, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Other Financial Transaction'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Personal Loan', NULL, 10, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Personal Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Bank Draft / Pay Order / Bankers Cheque', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Bank Draft / Pay Order / Bankers Cheque'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Foreign Currency / Forex', NULL, 35, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Foreign Currency / Forex'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Gold Bullion', NULL, 40, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Gold Bullion'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Gold Coins', NULL, 39, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Gold Coins'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Gold Jewellery', NULL, 38, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Gold Jewellery'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Goods or Services above prescribed limit', NULL, 37, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Goods or Services above prescribed limit'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Immovable Property', NULL, 28, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Immovable Property'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Motor Vehicle', NULL, 27, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Motor Vehicle'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Mutual Funds', NULL, 31, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Mutual Funds'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Purchase of Securities / Shares', NULL, 30, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Purchase of Securities / Shares'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Recurring Deposit', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Recurring Deposit'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sale of Gold Bullion', NULL, 43, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Sale of Gold Bullion'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sale of Gold Coins', NULL, 42, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Sale of Gold Coins'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sale of Gold Jewellery', NULL, 41, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Sale of Gold Jewellery'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sale of Immovable Property', NULL, 29, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Sale of Immovable Property'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sovereign Gold Bond Purchase', NULL, 45, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Sovereign Gold Bond Purchase'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Vehicle Loan', NULL, 12, NULL, true, false FROM m_code WHERE code_name = 'FORM_60_PURPOSE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'FORM_60_PURPOSE' AND v.code_value = 'Vehicle Loan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FEMALE', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'Gender' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Gender' AND v.code_value = 'FEMALE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MALE', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'Gender' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Gender' AND v.code_value = 'MALE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OTHER', 'Other', 4, 0, true, false FROM m_code WHERE code_name = 'Gender' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Gender' AND v.code_value = 'OTHER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TRANSGENDER', 'Transgender', 3, 0, true, false FROM m_code WHERE code_name = 'Gender' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Gender' AND v.code_value = 'TRANSGENDER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GROUP_COMPLETED', 'Group Objective Completed', 1, NULL, true, false FROM m_code WHERE code_name = 'GroupClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GroupClosureReason' AND v.code_value = 'GROUP_COMPLETED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INACTIVE', 'Group Inactive', 2, NULL, true, false FROM m_code WHERE code_name = 'GroupClosureReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GroupClosureReason' AND v.code_value = 'INACTIVE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Leader', 'Group Leader Role', 1, NULL, true, false FROM m_code WHERE code_name = 'GROUPROLE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GROUPROLE' AND v.code_value = 'Leader'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Business Associate', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'GuarantorRelationship' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GuarantorRelationship' AND v.code_value = 'Business Associate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'GuarantorRelationship' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GuarantorRelationship' AND v.code_value = 'Other'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Parent', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'GuarantorRelationship' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GuarantorRelationship' AND v.code_value = 'Parent'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sibling', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'GuarantorRelationship' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GuarantorRelationship' AND v.code_value = 'Sibling'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Spouse', NULL, 0, NULL, true, false FROM m_code WHERE code_name = 'GuarantorRelationship' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'GuarantorRelationship' AND v.code_value = 'Spouse'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FEE_INCOME', 'Fee Income', 2, NULL, true, false FROM m_code WHERE code_name = 'IncomeAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'IncomeAccountTags' AND v.code_value = 'FEE_INCOME'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'INTEREST_INCOME', 'Interest Income', 1, NULL, true, false FROM m_code WHERE code_name = 'IncomeAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'IncomeAccountTags' AND v.code_value = 'INTEREST_INCOME'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Accrual', 'Accrual', 2, NULL, true, false FROM m_code WHERE code_name = 'InterestCalculationTypeOption' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'InterestCalculationTypeOption' AND v.code_value = 'Accrual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Compounding', 'Compounding', 1, NULL, true, false FROM m_code WHERE code_name = 'InterestCalculationTypeOption' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'InterestCalculationTypeOption' AND v.code_value = 'Compounding'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Aadhaar Card', 'AADHAAR', 1, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Aadhaar Card'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Bank Account Statement / Passbook', 'BANK_STATEMENT', 10, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Bank Account Statement / Passbook'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Driving License', 'DRIVING_LICENSE', 3, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Driving License'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Electricity Bill', 'ELECTRICITY_BILL', 6, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Electricity Bill'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Employer Letter (for salaried employees)', 'EMPLOYER_LETTER', 14, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Employer Letter (for salaried employees)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gas Bill', 'GAS_BILL', 8, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Gas Bill'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Internet Bill', 'INTERNET_BILL', 9, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Internet Bill'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Passport', 'PASSPORT', 2, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Passport'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Pension Payment Orders (for retirees)', 'PENSION_PAYMENT_ORDER', 15, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Pension Payment Orders (for retirees)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Postpaid Mobile Bill', 'POSTPAID_MOBILE_BILL', 11, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Postpaid Mobile Bill'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Property Tax Receipt', 'PROPERTY_TAX_RECEIPT', 13, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Property Tax Receipt'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Registered Rent Agreement', 'RENT_AGREEMENT', 12, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Registered Rent Agreement'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Utility Bills', 'UTILITY_BILL', 5, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Utility Bills'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Voter ID Card', 'VOTER_ID', 4, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Voter ID Card'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Water Bill', 'WATER_BILL', 7, NULL, true, false FROM m_code WHERE code_name = 'KYCAddressProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCAddressProofType' AND v.code_value = 'Water Bill'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Aadhaar Card', 'AADHAAR', 2, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Aadhaar Card'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Authorized Signatory KYC', 'Authorized Signatory KYC', 10, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Authorized Signatory KYC'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Birth Certificate', 'BIRTH_CERTIFICATE', 8, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Birth Certificate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Board Resolution (authorizing signatories)', 'Board Resolution (authorizing signatories)', 11, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Board Resolution (authorizing signatories)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Board Resolution / Authorization Letter', 'Board Resolution / Authorization Letter', 12, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Board Resolution / Authorization Letter'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Certificate of Incorporation', 'Certificate of Incorporation', 13, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Certificate of Incorporation'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Certificate of Incorporation / Registration Certificate', 'Certificate of Incorporation / Registration Certificate', 14, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Certificate of Incorporation / Registration Certificate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Declaration by Karta (with name, address & HUF member details)', 'Declaration by Karta (with name, address & HUF member details)', 15, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Declaration by Karta (with name, address & HUF member details)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Designated Partners Resolution', 'Designated Partners Resolution', 16, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Designated Partners Resolution'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Driving License', 'DRIVING_LICENSE', 5, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Driving License'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Government Notification / Order', 'Government Notification / Order', 17, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Government Notification / Order'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GST Registration Certificate', 'GST Registration Certificate', 18, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'GST Registration Certificate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC of Authorized Signatories', 'KYC of Authorized Signatories', 19, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'KYC of Authorized Signatories'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC of Authorized Signatory / Power of Attorney Holder', 'KYC of Authorized Signatory / Power of Attorney Holder', 20, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'KYC of Authorized Signatory / Power of Attorney Holder'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC of Designated Partners / Authorized Signatories', 'KYC of Designated Partners / Authorized Signatories', 21, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'KYC of Designated Partners / Authorized Signatories'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC of Karta (OVDs + PAN + Photo)', 'KYC of Karta (OVDs + PAN + Photo)', 22, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'KYC of Karta (OVDs + PAN + Photo)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC of Trustees / Authorized Signatories', 'KYC of Trustees / Authorized Signatories', 23, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'KYC of Trustees / Authorized Signatories'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Letter issued by National Population Register (NPR)', 'NPR_LETTER', 7, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Letter issued by National Population Register (NPR)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LLP Agreement', 'LLP Agreement', 24, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'LLP Agreement'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Memorandum & Articles of Association', 'Memorandum & Articles of Association', 25, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Memorandum & Articles of Association'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', 26, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Memorandum & Articles of Association / Partnership Deed / Trust Deed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NREGA Job Card (signed by government authority)', 'NREGA_JOB_CARD', 6, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'NREGA Job Card (signed by government authority)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN', 'PAN', 27, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN (if applicable)', 'PAN (if applicable)', 28, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN (if applicable)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN Card', 'PAN', 1, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN Card'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN Card of Entity', 'PAN Card of Entity', 29, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN Card of Entity'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN Card of HUF', 'PAN Card of HUF', 30, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN Card of HUF'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of Company', 'PAN of Company', 31, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of Company'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of Firm', 'PAN of Firm', 32, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of Firm'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of LLP', 'PAN of LLP', 33, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of LLP'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of Proprietor', 'PAN of Proprietor', 34, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of Proprietor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of Society', 'PAN of Society', 35, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of Society'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PAN of Trust', 'PAN of Trust', 36, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'PAN of Trust'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Partnership Deed', 'Partnership Deed', 37, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Partnership Deed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Passport', 'PASSPORT', 3, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Passport'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Proof of Address of HUF', 'Proof of Address of HUF', 38, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Proof of Address of HUF'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Proof of Business Existence (GST Certificate / Shop License)', 'Proof of Business Existence (GST Certificate / Shop License)', 39, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Proof of Business Existence (GST Certificate / Shop License)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Proprietor''s KYC (OVDs + PAN + Photo)', 'Proprietor''s KYC (OVDs + PAN + Photo)', 40, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Proprietor''s KYC (OVDs + PAN + Photo)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Registration Certificate', 'Registration Certificate', 41, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Registration Certificate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Resolution authorizing signatories', 'Resolution authorizing signatories', 42, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Resolution authorizing signatories'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'School ID Card', 'SCHOOL_ID_CARD', 9, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'School ID Card'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Trust Deed', 'Trust Deed', 43, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Trust Deed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Voter ID Card (EPIC)', 'VOTER_ID', 4, NULL, true, false FROM m_code WHERE code_name = 'KYCIdProofType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'KYCIdProofType' AND v.code_value = 'Voter ID Card (EPIC)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CURRENT_LIABILITY', 'Current Liability', 1, NULL, true, false FROM m_code WHERE code_name = 'LiabilityAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LiabilityAccountTags' AND v.code_value = 'CURRENT_LIABILITY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'LONG_TERM_LIABILITY', 'Long Term Liability', 2, NULL, true, false FROM m_code WHERE code_name = 'LiabilityAccountTags' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LiabilityAccountTags' AND v.code_value = 'LONG_TERM_LIABILITY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FD', 'Fixed Deposit Lien', 4, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'FD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GOLD', 'Gold Pledge', 3, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'GOLD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GUARANTEE', 'Third Party Guarantee', 5, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'GUARANTEE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PROPERTY', 'Immovable Property (Land/Building)', 1, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'PROPERTY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'STOCK', 'Stock & Inventory Hypothecation', 6, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'STOCK'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'VEHICLE', 'Vehicle Hypothecation', 2, NULL, true, false FROM m_code WHERE code_name = 'LoanCollateral' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanCollateral' AND v.code_value = 'VEHICLE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AGGREGATOR', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginationChannelType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginationChannelType' AND v.code_value = 'AGGREGATOR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'API', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginationChannelType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginationChannelType' AND v.code_value = 'API'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'IN_STORE', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginationChannelType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginationChannelType' AND v.code_value = 'IN_STORE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ONLINE', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginationChannelType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginationChannelType' AND v.code_value = 'ONLINE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AFFILIATE', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginatorType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginatorType' AND v.code_value = 'AFFILIATE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BROKER', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginatorType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginatorType' AND v.code_value = 'BROKER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MERCHANT', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginatorType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginatorType' AND v.code_value = 'MERCHANT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PLATFORM', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'LoanOriginatorType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanOriginatorType' AND v.code_value = 'PLATFORM'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AGRICULTURE_LOAN', 'Agriculture Loan', 6, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'AGRICULTURE_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUSINESS_LOAN', 'Business Loan', 5, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'BUSINESS_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'EDUCATION_LOAN', 'Education Loan', 3, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'EDUCATION_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'HOME_LOAN', 'Housing Loan', 1, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'HOME_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PERSONAL_LOAN', 'Personal Loan', 4, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'PERSONAL_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'VEHICLE_LOAN', 'Vehicle Loan', 2, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'VEHICLE_LOAN'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'WORKING_CAPITAL', 'Working Capital Loan', 7, NULL, true, false FROM m_code WHERE code_name = 'LoanPurpose' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanPurpose' AND v.code_value = 'WORKING_CAPITAL'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUSINESS_LOSS', 'Business Loss', 3, NULL, true, false FROM m_code WHERE code_name = 'LoanRescheduleReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanRescheduleReason' AND v.code_value = 'BUSINESS_LOSS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FINANCIAL_DIFFICULTY', 'Customer facing financial issues', 1, NULL, true, false FROM m_code WHERE code_name = 'LoanRescheduleReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanRescheduleReason' AND v.code_value = 'FINANCIAL_DIFFICULTY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NATURAL_CALAMITY', 'Natural disaster impact', 2, NULL, true, false FROM m_code WHERE code_name = 'LoanRescheduleReason' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'LoanRescheduleReason' AND v.code_value = 'NATURAL_CALAMITY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'AGRICULTURE', 'Agriculture', 4, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'AGRICULTURE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CONSTRUCTION', 'Construction Business', 5, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'CONSTRUCTION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CONSULTING', 'Consulting / Professional Services', 15, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'CONSULTING'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'EDUCATION', 'Education Services', 11, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'EDUCATION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ENERGY', 'Energy / Power Sector', 19, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'ENERGY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'EXPORT_IMPORT', 'Export / Import Business', 16, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'EXPORT_IMPORT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FINANCIAL_SERVICES', 'Financial Services', 14, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'FINANCIAL_SERVICES'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FREELANCER', 'Freelancer / Self Employed', 23, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'FREELANCER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'GOVT_CONTRACTOR', 'Government Contractor', 22, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'GOVT_CONTRACTOR'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'HANDICRAFT', 'Handicraft / Artisan', 17, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'HANDICRAFT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'HEALTHCARE', 'Healthcare / Medical Services', 12, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'HEALTHCARE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'HOSPITALITY', 'Hotel / Restaurant / Hospitality', 13, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'HOSPITALITY'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'IT_SOFTWARE', 'IT / Software Services', 10, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'IT_SOFTWARE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MANUFACTURING', 'Manufacturing Industry', 1, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'MANUFACTURING'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MEDIA', 'Media / Advertising', 20, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'MEDIA'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MINING', 'Mining / Quarrying', 18, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'MINING'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NGO', 'NGO / Non-Profit Organization', 21, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'NGO'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OTHERS', 'Other Business Activity', 24, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'OTHERS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'REAL_ESTATE', 'Real Estate', 9, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'REAL_ESTATE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RETAIL', 'Retail Business', 6, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'RETAIL'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SERVICES', 'Service Sector', 3, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'SERVICES'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TRADING', 'Trading Business', 2, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'TRADING'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TRANSPORT', 'Transport & Logistics', 8, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'TRANSPORT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'WHOLESALE', 'Wholesale Business', 7, NULL, true, false FROM m_code WHERE code_name = 'Main Business Line' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Main Business Line' AND v.code_value = 'WHOLESALE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DIVORCED', 'Divorced', 3, NULL, true, false FROM m_code WHERE code_name = 'MARITAL STATUS' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MARITAL STATUS' AND v.code_value = 'DIVORCED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MARRIED', 'Married', 2, NULL, true, false FROM m_code WHERE code_name = 'MARITAL STATUS' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MARITAL STATUS' AND v.code_value = 'MARRIED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SINGLE', 'Unmarried', 1, NULL, true, false FROM m_code WHERE code_name = 'MARITAL STATUS' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MARITAL STATUS' AND v.code_value = 'SINGLE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'WIDOWED', 'Widowed', 4, NULL, true, false FROM m_code WHERE code_name = 'MARITAL STATUS' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MARITAL STATUS' AND v.code_value = 'WIDOWED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Annulled', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Annulled'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Divorced', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Divorced'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Married', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Married'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Not Disclosed', NULL, 10, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Not Disclosed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other', NULL, 9, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Other'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Separated', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Separated'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Single', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Single'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Unmarried', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Unmarried'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Widowed', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'MaritalStatus' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'MaritalStatus' AND v.code_value = 'Widowed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Agri Allied Activities', NULL, 38, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Agri Allied Activities'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Agricultural Labourer', NULL, 33, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Agricultural Labourer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Architect', NULL, 25, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Architect'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Artisan / Craftsman', NULL, 50, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Artisan / Craftsman'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Auditor', NULL, 30, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Auditor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Bank Employee', NULL, 9, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Bank Employee'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Business Owner / Proprietor', NULL, 11, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Business Owner / Proprietor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chartered Accountant', NULL, 23, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Chartered Accountant'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Commission Agent', NULL, 19, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Commission Agent'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Company Secretary', NULL, 24, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Company Secretary'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Consultant', NULL, 26, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Consultant'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Contractor', NULL, 14, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Contractor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dairy / Animal Husbandry', NULL, 35, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Dairy / Animal Husbandry'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Defence / Armed Forces Personnel', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Defence / Armed Forces Personnel'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dependent / Minor', NULL, 41, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Dependent / Minor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Doctor (Private Practice)', NULL, 21, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Doctor (Private Practice)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Doctor (Salaried)', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Doctor (Salaried)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Domestic Worker', NULL, 52, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Domestic Worker'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Driver / Operator', NULL, 53, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Driver / Operator'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Engineer (Salaried)', NULL, 8, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Engineer (Salaried)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Engineer (Self Employed)', NULL, 27, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Engineer (Self Employed)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Farmer (Own Land)', NULL, 31, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Farmer (Own Land)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Financial Advisor', NULL, 29, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Financial Advisor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Fisherman', NULL, 36, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Fisherman'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Government Employee', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Government Employee'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Horticulture / Floriculture', NULL, 34, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Horticulture / Floriculture'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Housewife / Homemaker', NULL, 40, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Housewife / Homemaker'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Importer / Exporter', NULL, 18, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Importer / Exporter'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'IT Professional / Freelancer', NULL, 28, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'IT Professional / Freelancer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Labourer / Daily Wage', NULL, 51, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Labourer / Daily Wage'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Lawyer / Advocate', NULL, 22, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Lawyer / Advocate'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Manufacturer', NULL, 13, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Manufacturer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NGO / Social Worker', NULL, 49, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'NGO / Social Worker'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NRI (Non-Resident Indian)', NULL, 45, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'NRI (Non-Resident Indian)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Others', NULL, 54, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Others'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PIO / OCI', NULL, 46, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'PIO / OCI'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Police / Paramilitary', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Police / Paramilitary'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Politician / Elected Representative', NULL, 47, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Politician / Elected Representative'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Poultry Farmer', NULL, 37, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Poultry Farmer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Private Sector Employee', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Private Sector Employee'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Public Sector Undertaking (PSU) Employee', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Public Sector Undertaking (PSU) Employee'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Railway Employee', NULL, 10, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Railway Employee'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Real Estate / Property Dealer', NULL, 15, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Real Estate / Property Dealer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Religious / Trust Worker', NULL, 48, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Religious / Trust Worker'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Retailer', NULL, 16, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Retailer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Retired (Non-Pension)', NULL, 44, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Retired (Non-Pension)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Retired (Pension)', NULL, 43, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Retired (Pension)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Student', NULL, 39, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Student'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Teacher / Professor', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Teacher / Professor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Tenant Farmer', NULL, 32, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Tenant Farmer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Trader / Merchant', NULL, 12, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Trader / Merchant'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Transport / Logistics Owner', NULL, 20, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Transport / Logistics Owner'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Unemployed', NULL, 42, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Unemployed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Wholesaler', NULL, 17, NULL, true, false FROM m_code WHERE code_name = 'Occupation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Occupation' AND v.code_value = 'Wholesaler'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Agriculture', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Agriculture'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Business / Self Employed', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Business / Self Employed'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Others', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Others'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Professional', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Professional'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Salaried / Service', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Salaried / Service'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Student / Non-Earning', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'OccupationCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'OccupationCategory' AND v.code_value = 'Student / Non-Earning'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Money Transfer', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'PaymentType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PaymentType' AND v.code_value = 'Money Transfer'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Half Yearly', 'Half Yearly', 3, NULL, true, false FROM m_code WHERE code_name = 'PeriodFrequency' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PeriodFrequency' AND v.code_value = 'Half Yearly'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Monthly', 'Monthly', 1, NULL, true, false FROM m_code WHERE code_name = 'PeriodFrequency' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PeriodFrequency' AND v.code_value = 'Monthly'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Quarterly', 'Quarterly', 2, NULL, true, false FROM m_code WHERE code_name = 'PeriodFrequency' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PeriodFrequency' AND v.code_value = 'Quarterly'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Yearly', 'Yearly', 4, NULL, true, false FROM m_code WHERE code_name = 'PeriodFrequency' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PeriodFrequency' AND v.code_value = 'Yearly'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'BUSINESS', 'Business Owner', 2, NULL, true, false FROM m_code WHERE code_name = 'PROFESSION' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PROFESSION' AND v.code_value = 'BUSINESS'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RETIRED', 'Retired Person', 5, NULL, true, false FROM m_code WHERE code_name = 'PROFESSION' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PROFESSION' AND v.code_value = 'RETIRED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SALARIED', 'Salaried Employee', 1, NULL, true, false FROM m_code WHERE code_name = 'PROFESSION' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PROFESSION' AND v.code_value = 'SALARIED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SELF_EMPLOYED', 'Self Employed Professional', 3, NULL, true, false FROM m_code WHERE code_name = 'PROFESSION' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PROFESSION' AND v.code_value = 'SELF_EMPLOYED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'STUDENT', 'Student', 4, NULL, true, false FROM m_code WHERE code_name = 'PROFESSION' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PROFESSION' AND v.code_value = 'STUDENT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Acid Attack Victim', 'Acid Attack Victim', 20, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Acid Attack Victim'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Autism Spectrum Disorder', 'Autism Spectrum Disorder', 9, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Autism Spectrum Disorder'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Blindness', 'Blindness', 1, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Blindness'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Cerebral Palsy', 'Cerebral Palsy', 10, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Cerebral Palsy'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chronic Neurological Conditions', 'Chronic Neurological Conditions', 12, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Chronic Neurological Conditions'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dwarfism', 'Dwarfism', 6, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Dwarfism'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Hearing Impairment (Deaf and Hard of Hearing)', 'Hearing Impairment (Deaf and Hard of Hearing)', 4, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Hearing Impairment (Deaf and Hard of Hearing)'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Hemophilia', 'Hemophilia', 17, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Hemophilia'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Intellectual Disability', 'Intellectual Disability', 7, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Intellectual Disability'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Leprosy Cured Persons', 'Leprosy Cured Persons', 3, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Leprosy Cured Persons'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Locomotor Disability', 'Locomotor Disability', 5, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Locomotor Disability'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Low Vision', 'Low Vision', 2, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Low Vision'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Mental Illness', 'Mental Illness', 8, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Mental Illness'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Multiple Disabilities', 'Multiple Disabilities', 19, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Multiple Disabilities'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Multiple Sclerosis', 'Multiple Sclerosis', 14, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Multiple Sclerosis'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Muscular Dystrophy', 'Muscular Dystrophy', 11, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Muscular Dystrophy'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Parkinson''s Disease', 'Parkinson''s Disease', 21, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Parkinson''s Disease'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sickle Cell Disease', 'Sickle Cell Disease', 18, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Sickle Cell Disease'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Specific Learning Disabilities', 'Specific Learning Disabilities', 13, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Specific Learning Disabilities'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Speech and Language Disability', 'Speech and Language Disability', 15, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Speech and Language Disability'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Thalassemia', 'Thalassemia', 16, NULL, true, false FROM m_code WHERE code_name = 'PwdCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'PwdCategory' AND v.code_value = 'Thalassemia'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DPD_RESET', 'DPD Reset After Payment', 1, NULL, true, false FROM m_code WHERE code_name = 'ReAgeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAgeReasons' AND v.code_value = 'DPD_RESET'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RESTRUCTURE_APPROVED', 'Restructure Approved', 2, NULL, true, false FROM m_code WHERE code_name = 'ReAgeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAgeReasons' AND v.code_value = 'RESTRUCTURE_APPROVED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TECHNICAL_ADJUSTMENT', 'Technical Adjustment', 3, NULL, true, false FROM m_code WHERE code_name = 'ReAgeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAgeReasons' AND v.code_value = 'TECHNICAL_ADJUSTMENT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'EMI_REDUCTION', 'EMI Reduction', 2, NULL, true, false FROM m_code WHERE code_name = 'ReAmortizationReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAmortizationReasons' AND v.code_value = 'EMI_REDUCTION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'PART_PREPAYMENT', 'After Part Prepayment', 4, NULL, true, false FROM m_code WHERE code_name = 'ReAmortizationReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAmortizationReasons' AND v.code_value = 'PART_PREPAYMENT'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'RATE_REVISION', 'Interest Rate Revision', 3, NULL, true, false FROM m_code WHERE code_name = 'ReAmortizationReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAmortizationReasons' AND v.code_value = 'RATE_REVISION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'TENURE_EXTENSION', 'Loan Tenure Extension', 1, NULL, true, false FROM m_code WHERE code_name = 'ReAmortizationReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'ReAmortizationReasons' AND v.code_value = 'TENURE_EXTENSION'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Rd Account', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'RecurringDepositProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RecurringDepositProductAccountType' AND v.code_value = 'Rd Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Individual', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'RecurringDepositProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RecurringDepositProductCustomerSegment' AND v.code_value = 'Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Non-Individual', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'RecurringDepositProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RecurringDepositProductCustomerSegment' AND v.code_value = 'Non-Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'CARETAKER', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'CARETAKER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DAUGHTER', 'Daughter', 5, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'DAUGHTER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FATHER', 'Father', 1, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'FATHER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'MOTHER', 'Mother', 2, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'MOTHER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SON', 'Son', 4, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'SON'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SPOUSE', 'Spouse', 3, NULL, true, false FROM m_code WHERE code_name = 'RELATIONSHIP' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'RELATIONSHIP' AND v.code_value = 'SPOUSE'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Buddhist', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Buddhist'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Christian', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Christian'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Hindu', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Hindu'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Jain', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Jain'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Muslim', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Muslim'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Others', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Others'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sikh', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'Religion' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Religion' AND v.code_value = 'Sikh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dr.', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Dr.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Master', NULL, 8, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Master'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Miss', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Miss'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Mr.', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Mr.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Mrs.', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Mrs.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Ms.', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Ms.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Shri', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Shri'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Smt.', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'Salutation' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'Salutation' AND v.code_value = 'Smt.'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'COURT_ORDER', 'Court Order Freeze', 2, NULL, true, false FROM m_code WHERE code_name = 'SavingsAccountBlockReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsAccountBlockReasons' AND v.code_value = 'COURT_ORDER'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'KYC_PENDING', 'KYC Pending', 1, NULL, true, false FROM m_code WHERE code_name = 'SavingsAccountBlockReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsAccountBlockReasons' AND v.code_value = 'KYC_PENDING'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Savings Account Minor', 'Savings Account Minor', 1, NULL, true, false FROM m_code WHERE code_name = 'SavingsProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsProductAccountType' AND v.code_value = 'Savings Account Minor'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Savings HUF', 'Savings HUF', 3, NULL, true, false FROM m_code WHERE code_name = 'SavingsProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsProductAccountType' AND v.code_value = 'Savings HUF'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Small Saving Account', 'Small Saving Account', 2, NULL, true, false FROM m_code WHERE code_name = 'SavingsProductAccountType' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsProductAccountType' AND v.code_value = 'Small Saving Account'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Individual', 'Individual', 1, NULL, true, false FROM m_code WHERE code_name = 'SavingsProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsProductCustomerSegment' AND v.code_value = 'Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Non Individual', 'Non Individual', 3, NULL, true, false FROM m_code WHERE code_name = 'SavingsProductCustomerSegment' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsProductCustomerSegment' AND v.code_value = 'Non Individual'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ACCOUNT_REVIEW', 'Account Under Review', 1, NULL, true, false FROM m_code WHERE code_name = 'SavingsTransactionFreezeReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SavingsTransactionFreezeReasons' AND v.code_value = 'ACCOUNT_REVIEW'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'General', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'SocialCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SocialCategory' AND v.code_value = 'General'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'OBC', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'SocialCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SocialCategory' AND v.code_value = 'OBC'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'SC', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'SocialCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SocialCategory' AND v.code_value = 'SC'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'ST', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'SocialCategory' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SocialCategory' AND v.code_value = 'ST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Andaman and Nicobar Islands', 'Union Territory', 29, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Andaman and Nicobar Islands'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Andhra Pradesh', 'Indian State', 1, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Andhra Pradesh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Arunachal Pradesh', 'Indian State', 2, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Arunachal Pradesh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Assam', 'Indian State', 3, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Assam'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Bihar', 'Indian State', 4, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Bihar'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chandigarh', 'Union Territory', 30, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Chandigarh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chhattisgarh', 'Indian State', 5, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Chhattisgarh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dadra and Nagar Haveli and Daman and Diu', 'Union Territory', 31, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Dadra and Nagar Haveli and Daman and Diu'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Delhi', 'Union Territory', 3, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Delhi'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Goa', 'Indian State', 6, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Goa'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gujarat', 'Indian State', 7, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Gujarat'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Haryana', 'Indian State', 8, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Haryana'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Himachal Pradesh', 'Indian State', 9, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Himachal Pradesh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Jammu and Kashmir', 'Union Territory', 33, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Jammu and Kashmir'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Jharkhand', 'Indian State', 10, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Jharkhand'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Karnataka', 'Indian State', 11, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Karnataka'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kerala', 'Indian State', 12, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Kerala'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Ladakh', 'Union Territory', 34, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Ladakh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Lakshadweep', 'Union Territory', 35, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Lakshadweep'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Madhya Pradesh', 'Indian State', 2, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Madhya Pradesh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Maharashtra', 'Indian State', 1, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Maharashtra'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Manipur', 'Indian State', 15, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Manipur'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Meghalaya', 'Indian State', 16, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Meghalaya'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Mizoram', 'Indian State', 17, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Mizoram'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Nagaland', 'Indian State', 18, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Nagaland'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Odisha', 'Indian State', 19, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Odisha'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Puducherry', 'Union Territory', 36, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Puducherry'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Punjab', 'Indian State', 20, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Punjab'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Rajasthan', 'Indian State', 21, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Rajasthan'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sikkim', 'Indian State', 22, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Sikkim'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Tamil Nadu', 'Indian State', 23, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Tamil Nadu'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Telangana', 'Indian State', 24, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Telangana'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Tripura', 'Indian State', 25, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Tripura'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Uttar Pradesh', 'Indian State', 26, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Uttar Pradesh'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Uttarakhand', 'Indian State', 27, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'Uttarakhand'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'West Bengal', 'Indian State', 28, NULL, true, false FROM m_code WHERE code_name = 'STATE' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'STATE' AND v.code_value = 'West Bengal'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Baiga', NULL, 43, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Baiga'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Balmiki', NULL, 31, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Balmiki'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Banjara', NULL, 20, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Banjara'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Bharia', NULL, 44, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Bharia'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Bhil', NULL, 41, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Bhil'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Brahmin', NULL, 1, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Brahmin'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Chamar / Jatav', NULL, 30, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Chamar / Jatav'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Dhobi', NULL, 32, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Dhobi'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Gond', NULL, 40, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Gond'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Jat', NULL, 5, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Jat'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kayastha', NULL, 4, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kayastha'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kewat / Dhimar', NULL, 14, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kewat / Dhimar'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kori', NULL, 35, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kori'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Korku', NULL, 42, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Korku'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kshatriya / Rajput', NULL, 2, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kshatriya / Rajput'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kumhar', NULL, 15, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kumhar'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kurmi', NULL, 11, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kurmi'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Kushwaha / Patel', NULL, 16, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Kushwaha / Patel'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Lohar', NULL, 18, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Lohar'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Mali', NULL, 17, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Mali'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Meghwal', NULL, 34, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Meghwal'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Nai / Napit', NULL, 13, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Nai / Napit'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other General', NULL, 7, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Other General'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other OBC', NULL, 21, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Other OBC'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other SC', NULL, 36, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Other SC'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Other ST', NULL, 46, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Other ST'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Pasi', NULL, 33, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Pasi'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Prajapati', NULL, 19, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Prajapati'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sahariya', NULL, 45, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Sahariya'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Sindhi', NULL, 6, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Sindhi'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Teli', NULL, 12, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Teli'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Vaishya / Baniya', NULL, 3, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Vaishya / Baniya'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'Yadav', NULL, 10, NULL, true, false FROM m_code WHERE code_name = 'SubCaste' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'SubCaste' AND v.code_value = 'Yadav'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'DECEASED', 'Customer Deceased', 2, NULL, true, false FROM m_code WHERE code_name = 'WriteOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'WriteOffReasons' AND v.code_value = 'DECEASED'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'FRAUD', 'Fraud Account', 3, NULL, true, false FROM m_code WHERE code_name = 'WriteOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'WriteOffReasons' AND v.code_value = 'FRAUD'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NPA', 'Non Performing Asset', 1, NULL, true, false FROM m_code WHERE code_name = 'WriteOffReasons' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'WriteOffReasons' AND v.code_value = 'NPA'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'NO', 'No Option', 2, NULL, true, false FROM m_code WHERE code_name = 'YesNo' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'YesNo' AND v.code_value = 'NO'); +INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory) SELECT id, 'YES', 'Yes Option', 1, NULL, true, false FROM m_code WHERE code_name = 'YesNo' AND NOT EXISTS (SELECT 1 FROM m_code_value v JOIN m_code c ON c.id = v.code_id WHERE c.code_name = 'YesNo' AND v.code_value = 'YES'); + + + + + \ No newline at end of file diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_catchup_context_default_client_kyc_migrations.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_catchup_context_default_client_kyc_migrations.xml new file mode 100644 index 000000000..5c3190144 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0506_catchup_context_default_client_kyc_migrations.xml @@ -0,0 +1,534 @@ + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OPENPROJECT' + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_OPENPROJECT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_client_kyc + SET kyc_status = 300, verified_on = CURRENT_DATE + WHERE kyc_type = 1 AND pan_verified = true AND aadhaar_verified = true + AND kyc_status != 300 AND kyc_status != 400; + + UPDATE m_client_kyc + SET kyc_status = 300, verified_on = CURRENT_DATE + WHERE kyc_type = 2 AND pan_verified = true AND gstin_verified = true + AND kyc_status != 300 AND kyc_status != 400; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_CLIENTNOMINEE' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_FAMILYMEMBERDOCUMENT' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_address SET client_id = ca.client_id + FROM m_client_address ca + WHERE m_address.id = ca.address_id AND m_address.client_id IS NULL; + + + + + + + + UPDATE m_field_configuration SET is_enabled = true WHERE field = 'townVillage' AND entity = 'ADDRESS'; + UPDATE m_field_configuration SET is_enabled = true WHERE field = 'countyDistrict' AND entity = 'ADDRESS'; + + + + + SELECT COUNT(*) FROM m_field_configuration WHERE entity='ADDRESS' AND field='street' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_address SET address_type_id = ca.address_type_id + FROM m_client_address ca + WHERE m_address.id = ca.address_id AND m_address.address_type_id IS NULL; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_simplify_teller_payment_types.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_simplify_teller_payment_types.xml new file mode 100644 index 000000000..60fc0d3a4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0507_simplify_teller_payment_types.xml @@ -0,0 +1,72 @@ + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE is_cash_payment = true + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE value = 'Money Transfer' + + + + + value = 'Money Transfer' + + + + + + UPDATE m_payment_type SET is_system_defined = true + WHERE code_name IN ('SHARE_REDEMPTION_TRANSFER', 'DIVIDEND_CLAIM_SETTLEMENT') AND is_system_defined = false; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_retire_share_redemption_transfer_payment_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_retire_share_redemption_transfer_payment_type.xml new file mode 100644 index 000000000..91261848b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0508_retire_share_redemption_transfer_payment_type.xml @@ -0,0 +1,60 @@ + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER' + + + UPDATE m_payment_detail SET payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'TRANSFER' OR value = 'Transfer' LIMIT 1) + WHERE payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER'); + + UPDATE acc_product_mapping SET payment_type = (SELECT id FROM m_payment_type WHERE code_name = 'TRANSFER' OR value = 'Transfer' LIMIT 1) + WHERE payment_type = (SELECT id FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER'); + + UPDATE m_charge SET payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'TRANSFER' OR value = 'Transfer' LIMIT 1) + WHERE payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER'); + + UPDATE m_clerk_share_withdrawal_request SET settlement_payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'TRANSFER' OR value = 'Transfer' LIMIT 1) + WHERE settlement_payment_type_id = (SELECT id FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER') + OR (settlement_payment_type_id IS NULL AND status = 'APPROVED'); + + DELETE FROM m_payment_type WHERE code_name = 'SHARE_REDEMPTION_TRANSFER'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_retire_all_system_defined_payment_types.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_retire_all_system_defined_payment_types.xml new file mode 100644 index 000000000..6586f5e9d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0509_retire_all_system_defined_payment_types.xml @@ -0,0 +1,69 @@ + + + + + + + + + SELECT COUNT(*) FROM m_payment_type WHERE is_system_defined = true + + + + UPDATE m_payment_detail SET payment_type_id = (SELECT id FROM m_payment_type WHERE value = 'Transfer' LIMIT 1) + WHERE payment_type_id IN (SELECT id FROM m_payment_type WHERE is_system_defined = true); + + UPDATE acc_product_mapping SET payment_type = (SELECT id FROM m_payment_type WHERE value = 'Transfer' LIMIT 1) + WHERE payment_type IN (SELECT id FROM m_payment_type WHERE is_system_defined = true); + + UPDATE m_charge SET payment_type_id = (SELECT id FROM m_payment_type WHERE value = 'Transfer' LIMIT 1) + WHERE payment_type_id IN (SELECT id FROM m_payment_type WHERE is_system_defined = true); + + UPDATE m_clerk_share_withdrawal_request SET settlement_payment_type_id = (SELECT id FROM m_payment_type WHERE value = 'Transfer' LIMIT 1) + WHERE settlement_payment_type_id IN (SELECT id FROM m_payment_type WHERE is_system_defined = true); + + DELETE FROM m_payment_type WHERE is_system_defined = true; + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FacilityChargeMappingData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FacilityChargeMappingData.java new file mode 100644 index 000000000..b21481595 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FacilityChargeMappingData.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.data; + +import java.math.BigDecimal; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +/** + * One row of {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge} + * is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled. {@link #chargeOptions} + * is populated only on the template response, for the admin screen's "change the mapped charge" dropdown. + */ +@Getter +@Setter +public class FacilityChargeMappingData { + + private Long id; + private String facilityType; + private Long chargeId; + private String chargeName; + private BigDecimal defaultAmount; + private Integer chargeTimeType; + private Integer chargeAppliesTo; + private Boolean isActive; + + /** + * False when the mapped charge has since been deleted or deactivated (and true whenever there is simply no + * charge mapped at all). {@link #chargeName} still shows whatever name was cached at mapping time either + * way, so the admin screen needs this flag to tell "correctly mapped" apart from "was mapped, now broken". + */ + private Boolean chargeAvailable; + + private List chargeOptions; + + public FacilityChargeMappingData() {} + + public FacilityChargeMappingData(final Long id, final String facilityType, final Long chargeId, final String chargeName, + final BigDecimal defaultAmount, final Integer chargeTimeType, final Integer chargeAppliesTo, final Boolean isActive, + final Boolean chargeAvailable) { + this.id = id; + this.facilityType = facilityType; + this.chargeId = chargeId; + this.chargeName = chargeName; + this.defaultAmount = defaultAmount; + this.chargeTimeType = chargeTimeType; + this.chargeAppliesTo = chargeAppliesTo; + this.isActive = isActive; + this.chargeAvailable = chargeAvailable; + } + + @Getter + @Setter + public static class FacilityChargeOptionData { + + private final Long id; + private final String name; + private final BigDecimal amount; + + public FacilityChargeOptionData(final Long id, final String name, final BigDecimal amount) { + this.id = id; + this.name = name; + this.amount = amount; + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/FacilityChargeMappingNotFoundException.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/FacilityChargeMappingNotFoundException.java new file mode 100644 index 000000000..01cb3eeb2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/exception/FacilityChargeMappingNotFoundException.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class FacilityChargeMappingNotFoundException extends AbstractPlatformResourceNotFoundException { + + public FacilityChargeMappingNotFoundException(final Long id) { + super("error.msg.facilitychargemapping.id.invalid", "Facility charge mapping with identifier " + id + " does not exist", id); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java index d7bbf84bc..892629e2a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeAutoApplyService.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.charge.domain.Charge; @@ -117,18 +118,13 @@ public class FacilityChargeAutoApplyService { public List applyChargesForFacilities(final SavingsAccount account, final java.util.Set facilityTypes) { final List chargesToApply = new ArrayList<>(); for (final String facilityType : facilityTypes) { - mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { - final Charge charge = mapping.getCharge(); - if (charge == null) { - return; - } - final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); - final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(), - null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval()); - accountCharge.update(account); - account.charges().add(accountCharge); - chargesToApply.add(accountCharge); - }); + final Charge charge = requireMappedCharge(facilityType); + final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount(charge, charge.getAmount(), + null, null, resolveDueDate(charge), true, feeOnMonthDay, charge.getFeeInterval()); + accountCharge.update(account); + account.charges().add(accountCharge); + chargesToApply.add(accountCharge); } if (!chargesToApply.isEmpty()) { savingsAccountChargeRepository.saveAll(chargesToApply); @@ -219,25 +215,49 @@ public class FacilityChargeAutoApplyService { && c.getCharge().getName().equals(getChargeName(facilityType))); if (alreadyApplied) return; - mappingRepository.findByFacilityType(facilityType).ifPresent(mapping -> { - final Charge charge = mapping.getCharge(); - if (charge == null) return; + final Charge charge = requireMappedCharge(facilityType); - final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); - final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( - charge, - charge.getAmount(), - null, // chargeTime derived from Charge definition - null, // chargeCalculation derived from Charge definition - resolveDueDate(charge), - true, - feeOnMonthDay, - charge.getFeeInterval()); + final MonthDay feeOnMonthDay = resolveFeeOnMonthDay(charge); + final SavingsAccountCharge accountCharge = SavingsAccountCharge.createNewWithoutSavingsAccount( + charge, + charge.getAmount(), + null, // chargeTime derived from Charge definition + null, // chargeCalculation derived from Charge definition + resolveDueDate(charge), + true, + feeOnMonthDay, + charge.getFeeInterval()); - accountCharge.update(account); - account.charges().add(accountCharge); - collector.add(accountCharge); - }); + accountCharge.update(account); + account.charges().add(accountCharge); + collector.add(accountCharge); + } + + /** + * Looks up the charge mapped to {@code facilityType}, throwing rather than silently letting the facility be + * enabled with no charge (or a charge that no longer exists) if the mapping is missing, has no charge + * configured, or its charge has since been deleted/deactivated. Configure it via Products {@literal >} + * Facility Charge Mappings first. + */ + private Charge requireMappedCharge(final String facilityType) { + final FacilityChargeMapping mapping = mappingRepository.findByFacilityType(facilityType).orElse(null); + // An inactive mapping is treated the same as an unmapped one: the Facility Charge Mappings screen's + // "Active" toggle is how an admin marks a mapping as genuinely configured, so a charge sitting on an + // inactive row must not silently apply. + if (mapping == null || mapping.getCharge() == null || !Boolean.TRUE.equals(mapping.getIsActive())) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", + "A charge has not been mapped for the '" + facilityType + + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + final Charge charge = mapping.getCharge(); + if (charge.isDeleted() || !charge.isActive()) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", + "The charge mapped to the '" + facilityType + + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + return charge; } private MonthDay resolveFeeOnMonthDay(final Charge charge) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformService.java new file mode 100644 index 000000000..d7e2794d2 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformService.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.util.List; +import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData; + +public interface FacilityChargeMappingReadPlatformService { + + List retrieveAll(); + + FacilityChargeMappingData retrieveOne(Long id); + + /** All active charges, for the admin screen's "change the mapped charge" dropdown. */ + FacilityChargeMappingData retrieveTemplate(); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformServiceImpl.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformServiceImpl.java new file mode 100644 index 000000000..5f868c671 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingReadPlatformServiceImpl.java @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService; +import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData; +import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData.FacilityChargeOptionData; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; +import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FacilityChargeMappingReadPlatformServiceImpl implements FacilityChargeMappingReadPlatformService { + + private final FacilityChargeMappingRepository facilityChargeMappingRepository; + private final ChargeReadPlatformService chargeReadPlatformService; + + @Override + public List retrieveAll() { + return facilityChargeMappingRepository.findAll().stream().sorted(Comparator.comparing(FacilityChargeMapping::getFacilityType)) + .map(this::toData).toList(); + } + + @Override + public FacilityChargeMappingData retrieveOne(final Long id) { + final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id) + .orElseThrow(() -> new FacilityChargeMappingNotFoundException(id)); + return toData(mapping); + } + + @Override + public FacilityChargeMappingData retrieveTemplate() { + final FacilityChargeMappingData template = new FacilityChargeMappingData(); + template.setChargeOptions(chargeReadPlatformService.retrieveAllCharges().stream() + .map(charge -> new FacilityChargeOptionData(charge.getId(), charge.getName(), charge.getAmount())).toList()); + return template; + } + + private FacilityChargeMappingData toData(final FacilityChargeMapping mapping) { + final Charge charge = mapping.getCharge(); + final boolean chargeAvailable = charge == null || (!charge.isDeleted() && charge.isActive()); + // FacilityChargeAutoApplyService always charges charge.getAmount() (the charge's own, live amount) -- + // mapping.getDefaultAmount() is a separate, unrelated stored column that nothing in the actual charging + // path reads, so showing it here would silently drift from what an account is really charged the moment + // someone edits the charge itself. Show the charge's real amount instead; edits to it happen on the + // Charge (Products > Charges), not here. + // When no charge is mapped, mapping.getChargeName()/getDefaultAmount() are stale leftovers from the + // pre-seed migration -- report null so the screen shows the facility as genuinely unmapped, not as + // if a charge (with a name and amount) were already chosen. + final String chargeName = charge != null ? charge.getName() : null; + final java.math.BigDecimal amount = charge != null ? charge.getAmount() : null; + return new FacilityChargeMappingData(mapping.getId(), mapping.getFacilityType(), charge == null ? null : charge.getId(), + chargeName, amount, mapping.getChargeTimeType(), mapping.getChargeAppliesTo(), mapping.getIsActive(), + chargeAvailable); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformService.java new file mode 100644 index 000000000..1a93d8e80 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformService.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface FacilityChargeMappingWritePlatformService { + + CommandProcessingResult updateMapping(Long id, JsonCommand command); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformServiceImpl.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformServiceImpl.java new file mode 100644 index 000000000..66471cef8 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeMappingWritePlatformServiceImpl.java @@ -0,0 +1,102 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMapping; +import org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository; +import org.apache.fineract.portfolio.savings.exception.FacilityChargeMappingNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FacilityChargeMappingWritePlatformServiceImpl implements FacilityChargeMappingWritePlatformService { + + private static final String CHARGE_ID_PARAM = "chargeId"; + private static final String IS_ACTIVE_PARAM = "isActive"; + + private final FacilityChargeMappingRepository facilityChargeMappingRepository; + private final ChargeRepositoryWrapper chargeRepositoryWrapper; + + @Transactional + @Override + public CommandProcessingResult updateMapping(final Long id, final JsonCommand command) { + final FacilityChargeMapping mapping = facilityChargeMappingRepository.findById(id) + .orElseThrow(() -> new FacilityChargeMappingNotFoundException(id)); + + final Map changes = new HashMap<>(); + + final Long existingChargeId = mapping.getCharge() == null ? null : mapping.getCharge().getId(); + if (command.isChangeInLongParameterNamed(CHARGE_ID_PARAM, existingChargeId)) { + final Long newChargeId = command.longValueOfParameterNamed(CHARGE_ID_PARAM); + final Charge charge = chargeRepositoryWrapper.findOneWithNotFoundDetection(newChargeId); + + // Each facility's charge must be its own: FacilityChargeAutoApplyService#applyIfEnabled dedups a + // newly-enabled facility's charge by matching an existing SavingsAccountCharge's charge NAME, so if + // two facilities shared the same charge, enabling the second one on an account that already has the + // first would be silently skipped as "already applied" -- under-charging the account by one facility + // worth of fee. Reject the mapping change here instead of letting that surface downstream. + final Optional conflict = facilityChargeMappingRepository.findAllByIsActiveTrue().stream() + .filter(other -> !other.getId().equals(mapping.getId())) // + .filter(other -> other.getCharge() != null && newChargeId.equals(other.getCharge().getId())) // + .findFirst(); + if (conflict.isPresent()) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.already.mapped", + "This charge is already mapped to the '" + conflict.get().getFacilityType() + + "' facility. Each charge can only be mapped to one facility, to avoid a shared charge being" + + " under-applied when both facilities are enabled on the same account.", + conflict.get().getFacilityType()); + } + + mapping.setCharge(charge); + mapping.setChargeName(charge.getName()); + // Keep the stored column in step with the charge actually being applied, even though nothing + // currently reads it directly (the read side reports charge.getAmount() live) -- avoids a future + // reader finding a stale value here. + mapping.setDefaultAmount(charge.getAmount()); + changes.put(CHARGE_ID_PARAM, newChargeId); + } + + if (command.isChangeInBooleanParameterNamed(IS_ACTIVE_PARAM, mapping.getIsActive())) { + final Boolean newIsActive = command.booleanObjectValueOfParameterNamed(IS_ACTIVE_PARAM); + mapping.setIsActive(newIsActive); + changes.put(IS_ACTIVE_PARAM, newIsActive); + } + + if (!changes.isEmpty()) { + facilityChargeMappingRepository.saveAndFlush(mapping); + } + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(id) // + .with(changes) // + .build(); + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java index 92c8c30e6..6eed63afb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java @@ -29,11 +29,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * Seeds default facility charges on service startup via JDBC. - * For each facility type (ATM, UPI, NEFT etc.), ensures a charge exists in m_charge - * and a mapping exists in m_facility_charge_mapping. - * Bank staff can change amounts from the Charges admin screen later. - * Seeder only creates records that are missing — safe to run repeatedly. + * Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI, + * NEFT etc.), so the Facility Charge Mappings admin screen has ready-made charges to choose from. + * Deliberately does NOT link a charge to its facility's m_facility_charge_mapping row or activate + * it — that mapping must be an explicit admin choice made through that screen (an unmapped facility + * is blocked from being enabled on an account by FacilityChargeAutoApplyService). Seeder only + * creates charges that are missing — safe to run repeatedly. */ @Service @RequiredArgsConstructor @@ -82,23 +83,13 @@ public class FacilityChargeSeederService { final int chargeTimeType = (int) def[2]; final int chargeAppliesTo = (int) def[3]; - // 1. Check if mapping row exists and already has a charge linked - List existingChargeId = jdbcTemplate.queryForList( - "SELECT charge_id FROM m_facility_charge_mapping WHERE facility_type = ? AND charge_id IS NOT NULL", - Long.class, facilityType); - - if (!existingChargeId.isEmpty()) { - continue; // Already seeded - } - - // 2. Check if charge with this name already exists in m_charge + // 1. Ensure a default charge exists in m_charge for this facility (creates it if missing) — + // purely so it's available as a ready-made option in the mapping screen's charge dropdown. List chargeIds = jdbcTemplate.queryForList( "SELECT id FROM m_charge WHERE name = ? AND is_deleted = false LIMIT 1", Long.class, chargeName); - Long chargeId; if (chargeIds.isEmpty()) { - // 3. Insert charge into m_charge jdbcTemplate.update( "INSERT INTO m_charge (name, currency_code, amount, charge_applies_to_enum, " + "charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, " @@ -106,29 +97,24 @@ public class FacilityChargeSeederService { + "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false, ?)", chargeName, amount, chargeAppliesTo, chargeTimeType, incomeGlAccountId); - chargeId = jdbcTemplate.queryForObject( - "SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id DESC LIMIT 1", - Long.class, chargeName); - - log.info("FacilityChargeSeeder: inserted charge '{}' id={}", chargeName, chargeId); - } else { - chargeId = chargeIds.get(0); + log.info("FacilityChargeSeeder: inserted charge '{}'", chargeName); } - // 4. Update or insert mapping row - int updated = jdbcTemplate.update( - "UPDATE m_facility_charge_mapping SET charge_id = ? WHERE facility_type = ?", - chargeId, facilityType); + // 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every + // facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge + // for it via the Facility Charge Mappings screen before it can be enabled on an account. + List existingMapping = jdbcTemplate.queryForList( + "SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType); - if (updated == 0) { + if (existingMapping.isEmpty()) { jdbcTemplate.update( "INSERT INTO m_facility_charge_mapping " + "(facility_type, charge_id, charge_name, default_amount, charge_time_type, charge_applies_to, is_active) " - + "VALUES (?, ?, ?, ?, ?, ?, 1)", - facilityType, chargeId, chargeName, amount, chargeTimeType, chargeAppliesTo); - } + + "VALUES (?, NULL, ?, ?, ?, ?, false)", + facilityType, chargeName, amount, chargeTimeType, chargeAppliesTo); - log.info("FacilityChargeSeeder: mapped facility '{}' → charge id={}", facilityType, chargeId); + log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType); + } } log.info("FacilityChargeSeeder: seeding complete."); From f746a1fb2c26f519b2920545ad80b10399f13f23 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 28 Aug 2026 14:36:59 +0530 Subject: [PATCH 16/55] feat(cashier,accounting): denomination-aware vault controls, Bank/Branch Master, Fund Transfer request type Closes the gap where a vault could hand out more cash (in total or in a specific note/coin) than it actually held: CashTransferApiResource now validates against the branch day's opening denomination breakdown plus today's settlements/transfers, and blocks entirely when no business day is open. BranchDayRegisterApiResource gains a real denomination breakdown per open/close leg (migration 0510), carried forward automatically day to day, and its book-cash formula no longer double-counts vault-to-cashier transfers as new cash in. CashReportApiResource adds a vault-position report (the teller/vault analogue of cashier-position) and fixes an off-by-one date comparison that excluded same-day transfers from a vault's anchor position. Institution/Branch Master: new InstitutionProfileApiResource and BranchProfileApiResource (migrations 0511-0513) give the bank a real "Bank Master" profile (identity, regulatory details, address, SWIFT/BIC) and each office its own branch-level fields (code, IFSC/MICR, working hours, geo-location) as side tables on m_office rather than touching the stock Fineract office entity. ClearingGlConfigApiResource exposes the previously config-only-via-SQL m_clearing_gl_config mapping (CTS/NACH/RTGS suspense accounts) as a proper admin API. ClerkDisbursementRequestApiResource now accepts a FUND_TRANSFER request type alongside DISBURSEMENT/COLLECTION/BANK_TRANSFER, and migration 0514 grants the teller-facing roles READ_CLIENTNOMINEE, which they were missing entirely. Co-Authored-By: Claude Sonnet 5 --- .../api/BranchDayRegisterApiResource.java | 161 +++++++++- .../cashreport/api/CashReportApiResource.java | 124 ++++++++ .../api/CashTransferApiResource.java | 123 ++++++++ .../api/ClearingGlConfigApiResource.java | 133 +++++++++ .../ClerkDisbursementRequestApiResource.java | 2 +- .../api/BranchProfileApiResource.java | 186 ++++++++++++ .../api/InstitutionProfileApiResource.java | 274 ++++++++++++++++++ .../tenant/final-changelog-tenant.xml | 5 + ...ranch_day_register_denomination_detail.xml | 49 ++++ .../0511_institution_and_branch_profile.xml | 135 +++++++++ ...and_institution_and_branch_identifiers.xml | 59 ++++ ...3_institution_profile_full_bank_master.xml | 78 +++++ ..._grant_teller_roles_read_clientnominee.xml | 28 ++ 13 files changed, 1351 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index ae56cd0b9..4810abbac 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -207,6 +207,7 @@ public class BranchDayRegisterApiResource { final LocalDate businessDate = extractDate(root, "businessDate", LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); + final List> denominations = extractDenominations(root, "denominations"); // A branch runs one day at a time. Opening a second while the first is still open is how a // day gets stranded, so the previous one has to be closed first. @@ -229,9 +230,17 @@ public class BranchDayRegisterApiResource { "Business day " + businessDate + " has already been opened and closed for this branch."); } - // Yesterday's closing is today's opening unless the caller states otherwise, so the carried - // figure does not have to be retyped and cannot drift. - final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate); + // A denomination breakdown given by the caller is the true source of the opening figure — + // its own total overrides a plain openingCash, the same way physically counting the drawer + // overrides whatever figure was expected. Without one, fall back to yesterday's own closing + // breakdown (if it recorded one) so the note/coin stock stays traceable day to day instead + // of resetting to "unknown" the moment nobody re-enters it; only when neither exists does + // this day open with a lump sum and no denomination detail at all. + final List> effectiveDenominations = !denominations.isEmpty() ? denominations + : (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of()); + final BigDecimal denominationTotal = sumDenominations(effectiveDenominations); + final BigDecimal carried = denominationTotal != null ? denominationTotal + : (openingCash != null ? openingCash : lastClosingCash(officeId, businessDate)); final Long id = jdbcTemplate.queryForObject(""" INSERT INTO m_branch_day_register @@ -242,6 +251,8 @@ public class BranchDayRegisterApiResource { Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), carried); + saveDenominationDetail(id, "OPENING", effectiveDenominations); + return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(), "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN")); } @@ -262,7 +273,12 @@ public class BranchDayRegisterApiResource { throw new PlatformDataIntegrityException("error.msg.branchday.date.required", "Which business day is being closed?"); } - final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); + final List> denominations = extractDenominations(root, "denominations"); + final BigDecimal denominationTotal = sumDenominations(denominations); + // A physical count at close is the same override relationship as at open: what was actually + // counted note-by-note wins over a typed-in total. + final BigDecimal closingCash = denominationTotal != null ? denominationTotal + : this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); @@ -300,6 +316,9 @@ public class BranchDayRegisterApiResource { bookCash, difference, isLate, isLate ? forceReason : null, remarks, officeId, Date.valueOf(businessDate)); + final Long registerId = ((Number) found.get(0).get("id")).longValue(); + saveDenominationDetail(registerId, "CLOSING", denominations); + final Map res = new LinkedHashMap<>(); res.put("officeId", officeId); res.put("businessDate", businessDate.toString()); @@ -311,8 +330,133 @@ public class BranchDayRegisterApiResource { return gson.toJson(res); } + // ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ──── + + /** + * The opening and closing denomination breakdown for one business day, if one was ever + * recorded — a day opened or closed with a plain typed-in amount and no breakdown returns an + * empty list for that leg rather than an error, since not every day is guaranteed to carry one. + */ + @GET + @Path("denominations") + public String denominations(@QueryParam("officeId") final Long officeIdParam, + @QueryParam("businessDate") final String businessDateParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank()) + ? LocalDate.parse(businessDateParam) + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final List registerIds = jdbcTemplate.queryForList( + "SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", + Long.class, officeId, Date.valueOf(businessDate)); + if (registerIds.isEmpty()) { + return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(), + "opening", List.of(), "closing", List.of())); + } + + final Long registerId = registerIds.get(0); + final List> rows = jdbcTemplate.queryForList(""" + SELECT leg, denomination_id, denomination_value, count, total_amount + FROM m_branch_day_register_detail + WHERE register_id = ? + ORDER BY denomination_value DESC + """, registerId); + + final List> opening = new ArrayList<>(); + final List> closing = new ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("denominationId", r.get("denomination_id")); + m.put("denominationValue", r.get("denomination_value")); + m.put("count", r.get("count")); + m.put("totalAmount", r.get("total_amount")); + if ("OPENING".equals(r.get("leg"))) { + opening.add(m); + } else { + closing.add(m); + } + } + return gson.toJson(Map.of("officeId", officeId, "businessDate", businessDate.toString(), + "opening", opening, "closing", closing)); + } + // ── Helpers ─────────────────────────────────────────────────────────────── + /** Reads a denominations[] array of {denominationId, denominationValue, count} into plain maps. */ + private List> extractDenominations(final com.google.gson.JsonElement root, final String field) { + if (!this.fromJsonHelper.parameterExists(field, root)) { + return List.of(); + } + final com.google.gson.JsonArray array = this.fromJsonHelper.extractJsonArrayNamed(field, root); + if (array == null) { + return List.of(); + } + final List> out = new ArrayList<>(); + for (final com.google.gson.JsonElement el : array) { + final com.google.gson.JsonObject obj = el.getAsJsonObject(); + final int count = obj.has("count") && !obj.get("count").isJsonNull() ? obj.get("count").getAsInt() : 0; + if (count <= 0) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("denominationId", obj.has("denominationId") && !obj.get("denominationId").isJsonNull() + ? obj.get("denominationId").getAsLong() : null); + row.put("denominationValue", obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull() + ? obj.get("denominationValue").getAsInt() : 0); + row.put("count", count); + out.add(row); + } + return out; + } + + private BigDecimal sumDenominations(final List> denominations) { + if (denominations == null || denominations.isEmpty()) { + return null; + } + BigDecimal total = BigDecimal.ZERO; + for (final Map row : denominations) { + final int value = (int) row.get("denominationValue"); + final int count = (int) row.get("count"); + total = total.add(BigDecimal.valueOf((long) value * count)); + } + return total; + } + + private void saveDenominationDetail(final Long registerId, final String leg, final List> denominations) { + if (denominations == null || denominations.isEmpty()) { + return; + } + for (final Map row : denominations) { + final int value = (int) row.get("denominationValue"); + final int count = (int) row.get("count"); + jdbcTemplate.update(""" + INSERT INTO m_branch_day_register_detail + (register_id, leg, denomination_id, denomination_value, count, total_amount) + VALUES (?,?,?,?,?,?) + """, registerId, leg, row.get("denominationId"), value, count, + BigDecimal.valueOf((long) value * count)); + } + } + + /** Yesterday's own closing breakdown, if it recorded one — the denomination-level analogue of lastClosingCash. */ + private List> lastClosingDenominations(final Long officeId, final LocalDate before) { + final List registerIds = jdbcTemplate.queryForList(""" + SELECT id FROM m_branch_day_register + WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL + ORDER BY business_date DESC LIMIT 1 + """, Long.class, officeId, Date.valueOf(before)); + if (registerIds.isEmpty()) { + return List.of(); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT denomination_id AS "denominationId", denomination_value AS "denominationValue", count + FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = 'CLOSING' + """, registerIds.get(0)); + return rows; + } + private List> mapDays(final List> rows) { final List> out = new ArrayList<>(); for (final Map r : rows) { @@ -378,6 +522,13 @@ public class BranchDayRegisterApiResource { * approved cash deposits less what went out as approved cash withdrawals. Deliberately derived * here and then stored on the row, so the figure the day was closed against survives any later * reversal. + * + * Vault-to-cashier transfers and cashier-to-vault settlements are shown for information but are + * NOT part of the total: they move cash between the branch's own vault and its own cashiers, so + * the branch's overall physical cash is unchanged either way. Adding the transfer and + * subtracting the settlement (an earlier version of this method did both) double-counts the same + * rupee — once as the day's opening figure, again as "new" cash arriving via the transfer — and + * manufactures a phantom shortage equal to whatever was handed to a cashier that day. */ private Map bookBreakdown(final Long officeId, final LocalDate businessDate) { final Date date = Date.valueOf(businessDate); @@ -416,7 +567,7 @@ public class BranchDayRegisterApiResource { WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false """, BigDecimal.class, date)); - final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut); + final BigDecimal expected = opening.add(deposits).subtract(withdrawals); final Map out = new LinkedHashMap<>(); out.put("openingCash", opening); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 95e444476..ec99ad3c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -533,6 +533,130 @@ public class CashReportApiResource { return gson.toJson(buildPositionForCashier(cashierId, dateFilter)); } + /** + * GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd + * Denomination-wise stock a vault (teller) is currently holding — "how much of what does the + * bank have", as opposed to cashier-position which answers the same question for a cashier. + * Anchored on the most recent business day (on or before asOfDate) whose branch day register + * recorded an OPENING denomination breakdown, then walked forward by everything settled back + * into this vault and handed out of it since. A vault whose opening days never recorded a + * breakdown (plain lump-sum opens only) has nothing to report per denomination — the response + * says so explicitly rather than silently returning zeros that look like an empty vault. + */ + @GET + @Path("vault-position") + public String vaultPosition(@QueryParam("tellerId") final Long tellerId, + @QueryParam("asOfDate") final String asOfDate) { + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + + if (tellerId == null) { + final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); + final List> all = new ArrayList<>(); + for (final Long id : tellerIds) { + all.add(buildPositionForVault(id, dateFilter)); + } + return gson.toJson(all); + } + return gson.toJson(buildPositionForVault(tellerId, dateFilter)); + } + + private Map buildPositionForVault(final Long tellerId, final String dateFilter) { + final Map result = new LinkedHashMap<>(); + result.put("tellerId", tellerId); + result.put("asOfDate", dateFilter); + + String tellerName = null; + Long officeId = null; + try { + final Map teller = jdbcTemplate.queryForMap( + "SELECT name, office_id FROM m_tellers WHERE id = ?", tellerId); + tellerName = (String) teller.get("name"); + officeId = ((Number) teller.get("office_id")).longValue(); + } catch (Exception ignored) { + // intentionally ignored + } + result.put("tellerName", tellerName); + + if (officeId == null) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + // The anchor: the most recent register on or before asOfDate that actually recorded an + // opening breakdown. Its breakdown already reflects every prior day's carry-forward (see + // BranchDayRegisterApiResource#lastClosingDenominations), so only movements from that one + // anchor date forward need to be added on top of it. + final List> anchorRows = jdbcTemplate.queryForList(""" + SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'OPENING') + ORDER BY r.business_date DESC LIMIT 1 + """, officeId, dateFilter); + + if (anchorRows.isEmpty()) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue(); + final String anchorDate = anchorRows.get(0).get("business_date").toString(); + + final Map balance = new LinkedHashMap<>(); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count + FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id + WHERE d.register_id = ? AND d.leg = 'OPENING' + """, DENOM_ROW, registerId), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count + FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id + LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id + WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) + AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE) + GROUP BY csd.denomination_id, csd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count + FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id + LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id + WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) + AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE) + GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), -1); + + final List> rows = new ArrayList<>(); + balance.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) + .forEach(e -> { + final Map row = new LinkedHashMap<>(); + row.put("denominationId", e.getKey()); + row.put("denominationValue", (int) e.getValue()[0]); + row.put("kind", e.getValue()[1] == 1L ? "coin" : "note"); + row.put("count", e.getValue()[2]); + row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2])); + rows.add(row); + }); + + final BigDecimal totalAmount = rows.stream() + .map(r -> (BigDecimal) r.get("amount")) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + result.put("hasBreakdown", true); + result.put("anchorDate", anchorDate); + result.put("totalAmount", totalAmount); + result.put("denominations", rows); + return result; + } + /** * GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD * Returns denomination-wise cash received by the cashier from vault on the given date diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 615b0623e..07a16f84e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -98,6 +98,8 @@ public class CashTransferApiResource { "At least one denomination with a positive count is required."); } + validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); header.setFromParty(fromParty); @@ -264,6 +266,127 @@ public class CashTransferApiResource { return total; } + /** + * A vault can only hand out cash it is actually holding — in total, and note-by-note. The + * branch's business day carries an opening_cash figure forward each day (see + * BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is + * that opening figure, plus anything a cashier has already settled back into this same vault + * today, minus whatever this vault has already handed out to any cashier today. Reversed + * transfers/settlements don't count. + * + * No open business day for the vault's office means there is no opening_cash to check against, so + * the transfer is blocked outright rather than silently allowed against an unknown balance. + */ + private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount, + final JsonArray denominations, final LocalDate transferDate) { + final Long officeId; + try { + officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId); + } catch (Exception e) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found", + "Vault/teller " + fromTellerId + " was not found."); + } + + final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate); + + final List registerIds = jdbcTemplate.query(""" + SELECT id FROM m_branch_day_register + WHERE office_id = ? AND business_date = ? AND closed_at IS NULL + """, (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate); + if (registerIds.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open", + "The business day for this branch is not open for " + businessDate + + ". Open the business day before allocating cash to a cashier."); + } + final Long registerId = registerIds.get(0); + + final BigDecimal openingCash = jdbcTemplate.queryForObject( + "SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId); + + final BigDecimal handedOutToday = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer + WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, fromTellerId, sqlBusinessDate); + + final BigDecimal settledBackToday = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement + WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false + """, BigDecimal.class, fromTellerId, sqlBusinessDate); + + final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday); + if (requestedAmount.compareTo(available) > 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash", + "Insufficient vault cash: available " + available + ", requested " + requestedAmount + "."); + } + + validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate); + } + + /** + * The total-amount check above can pass while a specific note/coin still runs short — a vault + * holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the + * total is well within its balance. This checks each requested denomination against what the + * vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's + * settlements-in minus today's transfers-out actually holds of that specific denomination. + * + * A day opened with a plain lump sum and no breakdown recorded has nothing to check a + * denomination against, so this is skipped rather than blocking every transfer on a day that + * simply never captured one. + */ + private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId, + final JsonArray denominations, final java.sql.Date businessDate) { + final List openingRows = jdbcTemplate.query(""" + SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = 'OPENING' + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId); + if (openingRows.isEmpty()) { + return; + } + + final Map available = new java.util.HashMap<>(); + for (final Object[] row : openingRows) { + available.merge((Long) row[0], (Integer) row[2], Integer::sum); + } + + final List settledRows = jdbcTemplate.query(""" + SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd + JOIN m_cash_settlement cs ON cs.id = csd.settlement_id + WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false + GROUP BY csd.denomination_id + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); + for (final Object[] row : settledRows) { + available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum); + } + + final List handedOutRows = jdbcTemplate.query(""" + SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd + JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id + WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false + GROUP BY ctd.denomination_id + """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); + for (final Object[] row : handedOutRows) { + available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum); + } + + for (final JsonElement el : denominations) { + final JsonObject item = el.getAsJsonObject(); + final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0; + if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) { + continue; + } + final long denominationId = item.get("denominationId").getAsLong(); + final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + ? item.get("denominationValue").getAsInt() : 0; + final int availableCount = available.getOrDefault(denominationId, 0); + if (count > availableCount) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination", + "Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount + + ", requested " + count + "."); + } + } + } + private boolean hasPositiveCount(JsonArray items) { for (JsonElement el : items) { JsonObject item = el.getAsJsonObject(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java new file mode 100644 index 000000000..f02f044af --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/api/ClearingGlConfigApiResource.java @@ -0,0 +1,133 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.clearing.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing + * role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are + * fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads + * them and updates which GL account one points at. + */ +@Path("/v1/clearingglconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ClearingGlConfigApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG"); + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code + FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id + ORDER BY c.config_key + """); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toResult(row)); + } + return gson.toJson(out); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG"); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code FROM acc_gl_account + WHERE disabled = false AND manual_journal_entries_allowed = true + ORDER BY name + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + options.add(o); + } + return gson.toJson(Map.of("glAccountOptions", options)); + } + + @PUT + @Path("{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG"); + + final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?", + Integer.class, configKey); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found", + "Clearing GL config key '" + configKey + "' was not found."); + } + + final var root = this.fromJsonHelper.parse(body); + final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root); + + if (glAccountId != null) { + final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?", + Integer.class, glAccountId); + if (accountExists == null || accountExists == 0) { + throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found", + "GL account " + glAccountId + " was not found."); + } + } + + jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId, + configKey); + + return retrieveAll(); + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("id", row.get("id")); + out.put("configKey", row.get("config_key")); + out.put("description", row.get("description")); + out.put("glAccountId", row.get("gl_account_id")); + out.put("glAccountName", row.get("gl_account_name")); + out.put("glCode", row.get("gl_code")); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 53913d2da..b2ad10e9f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -109,7 +109,7 @@ public class ClerkDisbursementRequestApiResource { final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root); String requestType = fromJsonHelper.extractStringNamed("requestType", root); if (requestType == null || requestType.isBlank() - || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) { + || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) { requestType = "DISBURSEMENT"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java new file mode 100644 index 000000000..286c3d817 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -0,0 +1,186 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +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.time.Instant; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The operational profile behind a branch — code, address, contact, type and working hours — kept as + * a side table on m_office (see migration 0511) rather than added to the core office entity, so this + * stays independent of the stock office create/edit flow. Every office already exists via the + * standard Offices screen; this only adds the real-world fields on top for the offices that have them + * set. An office with no profile row yet is a valid, ordinary state — not an error. + */ +@Path("/v1/branchprofile") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class BranchProfileApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String list(@QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission("BRANCHPROFILE"); + final String where = officeId != null ? "WHERE o.id = ?" : ""; + final List> rows = officeId != null + ? jdbcTemplate.queryForList(baseSql() + where + " ORDER BY o.hierarchy", officeId) + : jdbcTemplate.queryForList(baseSql() + " ORDER BY o.hierarchy"); + + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toResult(row)); + } + if (officeId != null) { + if (out.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + return gson.toJson(out.get(0)); + } + return gson.toJson(out); + } + + @PUT + @Path("{officeId}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(@PathParam("officeId") final Long officeId, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE"); + + final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", + Integer.class, officeId); + if (officeExists == null || officeExists == 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + + final var root = this.fromJsonHelper.parse(body); + final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); + final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); + final String address = this.fromJsonHelper.extractStringNamed("address", root); + final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); + final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); + final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); + final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root); + final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root); + final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root); + final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root); + final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root); + final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root); + + if (branchCode != null && !branchCode.isBlank()) { + final Integer dupe = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, + branchCode, officeId); + if (dupe != null && dupe > 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", + "Branch code '" + branchCode + "' is already used by another branch."); + } + } + + final AppUser currentUser = this.context.authenticatedUser(); + final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", + Integer.class, officeId); + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_office_profile + SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?, + working_hours_start = ?, working_hours_end = ?, working_days = ?, + ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, + updated_by_username = ?, updated_at = ? + WHERE office_id = ? + """, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now()), officeId); + } else { + jdbcTemplate.update(""" + INSERT INTO m_office_profile + (office_id, branch_code, branch_type, address, contact_phone, contact_email, + working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, + latitude, longitude, updated_by_username, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now())); + } + + return list(officeId); + } + + private String baseSql() { + return """ + SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date, + p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email, + p.working_hours_start, p.working_hours_end, p.working_days, + p.ifsc_code, p.micr_code, p.latitude, p.longitude, + p.updated_by_username, p.updated_at + FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id + """; + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("officeId", row.get("office_id")); + out.put("officeName", row.get("office_name")); + out.put("hierarchy", row.get("hierarchy")); + out.put("parentId", row.get("parent_id")); + final Object opening = row.get("opening_date"); + out.put("openingDate", opening == null ? null : opening.toString()); + out.put("branchCode", row.get("branch_code")); + out.put("branchType", row.get("branch_type")); + out.put("address", row.get("address")); + out.put("contactPhone", row.get("contact_phone")); + out.put("contactEmail", row.get("contact_email")); + out.put("workingHoursStart", row.get("working_hours_start")); + out.put("workingHoursEnd", row.get("working_hours_end")); + out.put("workingDays", row.get("working_days")); + out.put("ifscCode", row.get("ifsc_code")); + out.put("micrCode", row.get("micr_code")); + out.put("latitude", row.get("latitude")); + out.put("longitude", row.get("longitude")); + out.put("updatedByUsername", row.get("updated_by_username")); + final Object updatedAt = row.get("updated_at"); + out.put("updatedAt", updatedAt == null ? null : updatedAt.toString()); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java new file mode 100644 index 000000000..7bcf6fc7d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java @@ -0,0 +1,274 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The institution's own "Bank Master" — identity and classification, regulatory details, address + * (registered, with optional head-office/communication overrides), contact, the institution-level + * banking identifier (SWIFT/BIC — IFSC/MICR are per-branch and live on {@link BranchProfileApiResource} + * instead), document header names and terms text, the authorised representative, and audit fields. A + * singleton (always id=1): unlike offices there is only ever one bank running this tenant. + * + * Deliberately does NOT store Business Date, Financial Year, Time Zone, Working Days or Holiday + * Calendar — those already exist as their own configuration elsewhere in the system, and copying + * them here would just create a second, driftable value for the same fact. The currency shown is + * likewise derived live from {@code m_organisation_currency} rather than stored. + */ +@Path("/v1/institutionprofile") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class InstitutionProfileApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + @GET + public String get() { + this.context.authenticatedUser().validateHasReadPermission("INSTITUTIONPROFILE"); + ensureRowExists(); + final Map row = jdbcTemplate.queryForMap(""" + SELECT bank_name, legal_name, short_name, bank_code, institution_code, bank_type, + ownership_type, parent_institution_name, + registration_number, license_number, established_date, status, country, + regulatory_authority, registration_date, license_status, license_expiry_date, + swift_bic, + address_line1, address_line2, city, district, state, pincode, + head_office_same_as_registered, head_office_address, + communication_same_as_registered, communication_address, + contact_phone, contact_email, customer_care_number, grievance_email, website, + pan, tan, gstin, + statement_header_name, passbook_header_name, receipt_header_name, terms_and_conditions, + authorized_person_name, authorized_person_designation, authorized_person_contact, + authorized_person_email, + created_by_username, created_at, activation_date, deactivation_date, + updated_by_username, updated_at + FROM m_institution_profile WHERE id = 1 + """); + return gson.toJson(toResult(row)); + } + + @PUT + @Consumes({ MediaType.APPLICATION_JSON }) + public String update(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE"); + ensureRowExists(); + + final var root = this.fromJsonHelper.parse(body); + final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root); + final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root); + final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root); + final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root); + final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root); + final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root); + final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root); + final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root); + final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root); + final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root); + final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root)); + final String status = this.fromJsonHelper.extractStringNamed("status", root); + final String country = this.fromJsonHelper.extractStringNamed("country", root); + final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root); + final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root)); + final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root); + final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root)); + final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root); + final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root); + final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root); + final String city = this.fromJsonHelper.extractStringNamed("city", root); + final String district = this.fromJsonHelper.extractStringNamed("district", root); + final String state = this.fromJsonHelper.extractStringNamed("state", root); + final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); + final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root) + ? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE; + final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root); + final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root) + ? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE; + final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root); + final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); + final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); + final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root); + final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root); + final String website = this.fromJsonHelper.extractStringNamed("website", root); + final String pan = this.fromJsonHelper.extractStringNamed("pan", root); + final String tan = this.fromJsonHelper.extractStringNamed("tan", root); + final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root); + final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root); + final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root); + final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root); + final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root); + final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root); + final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation", + root); + final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root); + final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root); + + // A status flip is stamped automatically the first time it happens, rather than asking the + // user to also remember to set a date — the previous row's own status is what "the first + // time" is judged against. + final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", + String.class); + final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); + final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); + final LocalDate today = LocalDate.now(); + + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + UPDATE m_institution_profile + SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, + bank_type = ?, ownership_type = ?, parent_institution_name = ?, + registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, + regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, + swift_bic = ?, + address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, + head_office_same_as_registered = ?, head_office_address = ?, + communication_same_as_registered = ?, communication_address = ?, + contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, + website = ?, pan = ?, tan = ?, gstin = ?, + statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, + terms_and_conditions = ?, + authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, + authorized_person_email = ?, + activation_date = CASE WHEN ? THEN ? ELSE activation_date END, + deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, + updated_by_username = ?, updated_at = ? + WHERE id = 1 + """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, + parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country, + regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic, + addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress, + communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail, + website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions, + authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail, + turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(), + Timestamp.from(Instant.now())); + + return get(); + } + + private void ensureRowExists() { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1", + Integer.class); + if (count == null || count == 0) { + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + INSERT INTO m_institution_profile (id, status, created_by_username, created_at) + VALUES (1, 'ACTIVE', ?, ?) + """, currentUser.getUsername(), Timestamp.from(Instant.now())); + } + } + + /** The tenant's one enabled currency (see the INR-only enforcement migration) — read-only here. */ + private String resolveCurrencyCode() { + final List codes = jdbcTemplate.queryForList( + "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); + return codes.isEmpty() ? null : codes.get(0); + } + + private LocalDate parseDate(final String raw) { + return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; + } + + private Date toSqlDate(final LocalDate d) { + return d == null ? null : Date.valueOf(d); + } + + private Map toResult(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("bankName", row.get("bank_name")); + out.put("legalName", row.get("legal_name")); + out.put("shortName", row.get("short_name")); + out.put("bankCode", row.get("bank_code")); + out.put("institutionCode", row.get("institution_code")); + out.put("bankType", row.get("bank_type")); + out.put("ownershipType", row.get("ownership_type")); + out.put("parentInstitutionName", row.get("parent_institution_name")); + out.put("registrationNumber", row.get("registration_number")); + out.put("licenseNumber", row.get("license_number")); + out.put("establishedDate", str(row.get("established_date"))); + out.put("status", row.get("status")); + out.put("country", row.get("country")); + out.put("currencyCode", resolveCurrencyCode()); + out.put("regulatoryAuthority", row.get("regulatory_authority")); + out.put("registrationDate", str(row.get("registration_date"))); + out.put("licenseStatus", row.get("license_status")); + out.put("licenseExpiryDate", str(row.get("license_expiry_date"))); + out.put("swiftBic", row.get("swift_bic")); + out.put("addressLine1", row.get("address_line1")); + out.put("addressLine2", row.get("address_line2")); + out.put("city", row.get("city")); + out.put("district", row.get("district")); + out.put("state", row.get("state")); + out.put("pincode", row.get("pincode")); + out.put("headOfficeSameAsRegistered", row.get("head_office_same_as_registered")); + out.put("headOfficeAddress", row.get("head_office_address")); + out.put("communicationSameAsRegistered", row.get("communication_same_as_registered")); + out.put("communicationAddress", row.get("communication_address")); + out.put("contactPhone", row.get("contact_phone")); + out.put("contactEmail", row.get("contact_email")); + out.put("customerCareNumber", row.get("customer_care_number")); + out.put("grievanceEmail", row.get("grievance_email")); + out.put("website", row.get("website")); + out.put("pan", row.get("pan")); + out.put("tan", row.get("tan")); + out.put("gstin", row.get("gstin")); + out.put("statementHeaderName", row.get("statement_header_name")); + out.put("passbookHeaderName", row.get("passbook_header_name")); + out.put("receiptHeaderName", row.get("receipt_header_name")); + out.put("termsAndConditions", row.get("terms_and_conditions")); + out.put("authorizedPersonName", row.get("authorized_person_name")); + out.put("authorizedPersonDesignation", row.get("authorized_person_designation")); + out.put("authorizedPersonContact", row.get("authorized_person_contact")); + out.put("authorizedPersonEmail", row.get("authorized_person_email")); + out.put("createdByUsername", row.get("created_by_username")); + out.put("createdAt", str(row.get("created_at"))); + out.put("activationDate", str(row.get("activation_date"))); + out.put("deactivationDate", str(row.get("deactivation_date"))); + out.put("updatedByUsername", row.get("updated_by_username")); + out.put("updatedAt", str(row.get("updated_at"))); + return out; + } + + private static String str(final Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index fceb9115a..b238153fb 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -33,4 +33,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml new file mode 100644 index 000000000..4f9137cad --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0510_branch_day_register_denomination_detail.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml new file mode 100644 index 000000000..a6097ff92 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0511_institution_and_branch_profile.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_INSTITUTIONPROFILE' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_INSTITUTIONPROFILE', 'UPDATE_INSTITUTIONPROFILE', + 'READ_BRANCHPROFILE', 'UPDATE_BRANCHPROFILE', + 'READ_CLEARINGGLCONFIG', 'UPDATE_CLEARINGGLCONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml new file mode 100644 index 000000000..192a45e63 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0512_expand_institution_and_branch_identifiers.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml new file mode 100644 index 000000000..c3d46d6e4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0513_institution_profile_full_bank_master.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml new file mode 100644 index 000000000..1ed5e4267 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0514_grant_teller_roles_read_clientnominee.xml @@ -0,0 +1,28 @@ + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'READ_CLIENTNOMINEE' + AND r.name IN ('CASHIER', 'Clerk', 'Teller', 'Branch Manager', 'Principal Officer', 'As Manager') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + From 8d224a22b412af4c4e11df007eaece3c66206a8c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sat, 29 Aug 2026 11:01:30 +0530 Subject: [PATCH 17/55] fix(branchday): correct timestamp serialization, require a reason for an opening cash mismatch Opened-by/closed-by times on the day register were rendering blank: the JDBC Timestamp's own toString() separates date and time with a space, not the ISO-8601 'T' every frontend consumer parses on. str() now formats a Timestamp explicitly instead of falling through to the generic toString(). Opening a business day with a note/coin count or lump sum that did not match the branch's own closing figure from the day before was previously accepted with no record of why -- every downstream figure (vault position, EOD summary, the next day's own carry-forward) would then be built on an unexplained short or excess baseline with nothing to explain it later. The open endpoint now requires an openingDifferenceReason whenever the caller supplies a figure that differs from the prior close, enforced server-side so it can't be bypassed by calling the API directly; a day with no prior close, or one left to the automatic carry-forward untouched, is unaffected. Co-Authored-By: Claude Sonnet 5 --- .../api/BranchDayRegisterApiResource.java | 69 ++++++++++++++++--- .../tenant/final-changelog-tenant.xml | 1 + ...day_register_opening_difference_reason.xml | 22 ++++++ 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 4810abbac..29977ff75 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -80,7 +80,7 @@ public class BranchDayRegisterApiResource { final List> rows = jdbcTemplate.queryForList(""" SELECT r.id, r.office_id, o.name AS office_name, r.business_date, - r.opened_by_name, r.opened_at, r.opening_cash, + r.opened_by_name, r.opened_at, r.opening_cash, r.opening_difference_reason, r.closed_by_name, r.closed_at, r.closing_cash, r.book_cash, r.cash_difference, r.force_closed, r.force_close_reason, r.remarks @@ -116,7 +116,7 @@ public class BranchDayRegisterApiResource { final List> rows = jdbcTemplate.queryForList(""" SELECT r.id, r.office_id, o.name AS office_name, r.business_date, - r.opened_by_name, r.opened_at, r.opening_cash, + r.opened_by_name, r.opened_at, r.opening_cash, r.opening_difference_reason, r.closed_by_name, r.closed_at, r.closing_cash, r.book_cash, r.cash_difference, r.force_closed, r.force_close_reason, r.remarks @@ -208,6 +208,7 @@ public class BranchDayRegisterApiResource { LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); final List> denominations = extractDenominations(root, "denominations"); + final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); // A branch runs one day at a time. Opening a second while the first is still open is how a // day gets stranded, so the previous one has to be closed first. @@ -239,22 +240,45 @@ public class BranchDayRegisterApiResource { final List> effectiveDenominations = !denominations.isEmpty() ? denominations : (openingCash == null ? lastClosingDenominations(officeId, businessDate) : List.of()); final BigDecimal denominationTotal = sumDenominations(effectiveDenominations); + final boolean hasPreviousClose = hasPreviousClose(officeId, businessDate); + final BigDecimal previousClosingCash = hasPreviousClose ? lastClosingCash(officeId, businessDate) : null; final BigDecimal carried = denominationTotal != null ? denominationTotal - : (openingCash != null ? openingCash : lastClosingCash(officeId, businessDate)); + : (openingCash != null ? openingCash : (hasPreviousClose ? previousClosingCash : BigDecimal.ZERO)); + + // A figure the caller actually chose (typed a lump sum, or counted denominations) that does + // not match what the branch closed with yesterday has to say why. Left unchecked, a short or + // excess opening count is recorded with nothing explaining it, and every figure calculated + // from this day onward (vault position, EOD summary, tomorrow's own carry-forward) inherits + // that unexplained gap silently. A day left to the automatic carry-forward (no figure + // supplied at all) never triggers this — there is no choice being made to justify. + final boolean callerSuppliedFigure = !denominations.isEmpty() || openingCash != null; + final boolean opensWithADifference = callerSuppliedFigure && hasPreviousClose && carried != null + && carried.compareTo(previousClosingCash) != 0; + if (opensWithADifference && (openingDifferenceReason == null || openingDifferenceReason.isBlank())) { + throw new PlatformDataIntegrityException("error.msg.branchday.opening.difference.reason.required", + "Opening cash (Rs. " + carried + ") differs from yesterday's closing cash (Rs. " + + previousClosingCash + "). Record why before opening."); + } final Long id = jdbcTemplate.queryForObject(""" INSERT INTO m_branch_day_register - (office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed) - VALUES (?,?,?,?,?,?,false) + (office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed, + opening_difference_reason) + VALUES (?,?,?,?,?,?,false,?) RETURNING id """, Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(), - Timestamp.from(Instant.now()), carried); + Timestamp.from(Instant.now()), carried, opensWithADifference ? openingDifferenceReason.trim() : null); saveDenominationDetail(id, "OPENING", effectiveDenominations); - return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(), - "openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN")); + final Map res = new LinkedHashMap<>(); + res.put("resourceId", id); + res.put("officeId", officeId); + res.put("businessDate", businessDate.toString()); + res.put("openingCash", carried == null ? BigDecimal.ZERO : carried); + res.put("status", "OPEN"); + return gson.toJson(res); } // ── POST /v1/branchday/close ────────────────────────────────────────────── @@ -468,6 +492,7 @@ public class BranchDayRegisterApiResource { m.put("openedByName", r.get("opened_by_name")); m.put("openedAt", str(r.get("opened_at"))); m.put("openingCash", r.get("opening_cash")); + m.put("openingDifferenceReason", r.get("opening_difference_reason")); m.put("closedByName", r.get("closed_by_name")); m.put("closedAt", str(r.get("closed_at"))); m.put("closingCash", r.get("closing_cash")); @@ -482,14 +507,27 @@ public class BranchDayRegisterApiResource { return out; } + /** + * {@link Timestamp#toString()} separates the date and time with a space ("2026-08-28 + * 08:49:36.29679"), not the 'T' ISO-8601 uses — every consumer of these fields (the frontend's + * split-on-'T' time parsing, most of all) expects ISO-8601, so a plain Timestamp needs its own + * formatting rather than falling through to the generic toString(). + */ private static String str(final Object o) { - return o == null ? null : o.toString(); + if (o == null) { + return null; + } + if (o instanceof Timestamp) { + return ((Timestamp) o).toLocalDateTime().toString(); + } + return o.toString(); } /** The branch's currently open day, or null when none is open. */ private Map findOpenDay(final Long officeId) { final List> rows = jdbcTemplate.queryForList(""" - SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash + SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash, + opening_difference_reason FROM m_branch_day_register WHERE office_id = ? AND closed_at IS NULL ORDER BY business_date ASC LIMIT 1 @@ -504,9 +542,20 @@ public class BranchDayRegisterApiResource { m.put("openedByName", r.get("opened_by_name")); m.put("openedAt", str(r.get("opened_at"))); m.put("openingCash", r.get("opening_cash")); + m.put("openingDifferenceReason", r.get("opening_difference_reason")); return m; } + /** Whether this office has ever closed a business day before this date — the gate on the + * opening-difference check, since there is nothing to differ from on a branch's very first day. */ + private boolean hasPreviousClose(final Long officeId, final LocalDate before) { + final Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_branch_day_register + WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL + """, Integer.class, officeId, Date.valueOf(before)); + return count != null && count > 0; + } + /** The most recent closing figure before this date, so it carries forward as the opening. */ private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) { final List rows = jdbcTemplate.query(""" diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index b238153fb..fff9dac05 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -38,4 +38,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml new file mode 100644 index 000000000..fab11835d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0515_branch_day_register_opening_difference_reason.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + From 6ffb0dd9c0979d23906b4caabe9021948cea2ebb Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 10:37:53 +0530 Subject: [PATCH 18/55] fix(savings,cashier): auto-post pro-rata interest on closure, split Cash/Clearing hold, fix WITHDRAWN-status blind spots Closing a savings account used to only ever validate that interest had already been posted on the closing date (opt-in, off by default) -- with nothing posted beforehand the partial period's interest was simply never paid. It now always calculates and posts it before the account closes, reusing the same machinery the manual "post interest as of date" action already relied on. Savings accounts now track two separate hold totals instead of one: a manual/staff-placed Cash Hold (the existing lien mechanism, unchanged for every caller that doesn't ask for the new category) and a Clearing Hold for money still moving through the clearing process -- a Cheque/DD cashier deposit now automatically places one for the full amount at approval time, and CTS inward-clearing holds are now bucketed here too, since both are genuinely about the clearing pipeline rather than a lien someone chose to place. A new self-contained endpoint releases due Clearing Holds (wire it into an external scheduler); the existing manual release action now credits back whichever bucket the original hold actually debited. Approving a Cheque/DD deposit dispatches this automatically as the approving manager, so the checker roles that call deposit-approval needed the underlying HOLDAMOUNT/RELEASEAMOUNT permissions granted -- without it every such approval would now fail outright; a stray column-name bug in the payment- type lookup that surfaced this in testing is fixed too, guarded going forward by a try/catch so a problem in this best-effort step can never sink the deposit approval it rides along with. A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED -- deposits and disbursements use APPROVED, withdrawal is the one register that doesn't. Three places were still filtering on APPROVED alone and silently excluding every completed withdrawal: the Day Book's CR/DR totals, and the branch day register's own "cash the branch should be holding" formula (which overstated the expected figure by exactly what had been paid out, manufacturing a phantom cash-short every time a withdrawal happened that day). The GL Entries report showed a bare internal transaction id under "Voucher No" instead of the real formatted voucher stamped on the underlying deposit/withdrawal request; it now resolves and shows that instead. Denomination rows saved at deposit/withdrawal submission never recorded which payment type was used, always leaving "Payment Type" blank regardless of what the cashier actually collected -- both insert paths now carry it through, and existing rows are backfilled. FacilityChargeSeederService no longer hardcodes an income GL account onto the facility charges (ATM, UPI, Cheque Book, NEFT, ...) it seeds. Fineract's accounting engine uses a charge's own GL account ahead of its savings product's own "Income From Fees" mapping whenever one is set, so seeding all sixteen onto the same shared account (picked essentially by accident) silently overrode every product's own accounting configuration with it. Leaving it unset lets each product's own mapping decide where this income actually posts, which is the point of that mapping existing at all. Institution/Branch Profile's READ permission was scoped to manager/admin roles only, since it started as an org-setup-only screen -- now that the Passbook and Statement screens read it for their letterhead (real bank name, license number, branch IFSC/MICR) instead of a hardcoded name, and both are reachable by a cashier/clerk/teller, the read-only permission is extended to those roles too. Co-Authored-By: Claude Sonnet 5 --- .../savings/SavingsApiConstants.java | 3 + .../api/BranchDayRegisterApiResource.java | 6 +- .../api/CashierDepositRequestApiResource.java | 73 ++++++++++++++++-- .../cashreport/api/CashReportApiResource.java | 20 +++-- .../ClerkWithdrawalRequestApiResource.java | 7 +- .../CtsClearingWritePlatformServiceImpl.java | 4 +- .../InwardClearingValidationService.java | 5 +- ...avingsAccountTransactionDataValidator.java | 3 +- ...WritePlatformServiceJpaRepositoryImpl.java | 48 ++++++++---- ...SavingsClearingHoldReleaseApiResource.java | 77 +++++++++++++++++++ .../api/SavingsHoldBreakdownApiResource.java | 49 ++++++++++++ .../tenant/final-changelog-tenant.xml | 3 + .../0516_savings_account_clearing_hold.xml | 35 +++++++++ ...rant_checker_roles_hold_release_amount.xml | 31 ++++++++ ..._roles_read_institution_branch_profile.xml | 30 ++++++++ .../savings/domain/SavingsAccount.java | 42 +++++++++- .../domain/SavingsAccountTransaction.java | 14 ++++ .../service/FacilityChargeSeederService.java | 38 +++------ 18 files changed, 424 insertions(+), 64 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsClearingHoldReleaseApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsHoldBreakdownApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_savings_account_clearing_hold.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_grant_checker_roles_hold_release_amount.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_grant_teller_roles_read_institution_branch_profile.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index 8b6a61f18..8197a2477 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -92,6 +92,9 @@ public class SavingsApiConstants { public static final String statusParamName = "status"; public static final String subStatusParamName = "subStatus"; public static final String reasonForBlockParamName = "reasonForBlock"; + /** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the + * clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */ + public static final String holdCategoryParamName = "holdCategory"; public static final String clientIdParamName = "clientId"; public static final String isRetailAccountParamName = "isRetailAccount"; public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 29977ff75..3fc675902 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -598,9 +598,13 @@ public class BranchDayRegisterApiResource { AND (office_id = ? OR office_id IS NULL) """, BigDecimal.class, date, officeId)); + // A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and + // disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on + // APPROVED alone silently excluded every completed withdrawal from this total, overstating + // the branch's expected cash by exactly what had actually been paid out that day. final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request - WHERE status = 'APPROVED' + WHERE status = 'WITHDRAWN' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? """, BigDecimal.class, date)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 2474b9c70..17115fd52 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -67,6 +67,7 @@ import com.google.common.base.Splitter; @Component @Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.") @RequiredArgsConstructor +@lombok.extern.slf4j.Slf4j public class CashierDepositRequestApiResource { private final JdbcTemplate jdbcTemplate; @@ -161,7 +162,7 @@ public class CashierDepositRequestApiResource { // reject() will delete these records so the vault position is reversed. if (denominationData != null && !denominationData.isBlank()) { savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId, - currentUser.getId(), currentUser.getUsername()); + currentUser.getId(), currentUser.getUsername(), paymentTypeName); } return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo)); @@ -394,6 +395,20 @@ public class CashierDepositRequestApiResource { txnId = isCurrentAccount ? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) : executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + if (!isCurrentAccount && txnId != null) { + // approve() is one @Transactional block, so an uncaught exception here would roll back + // the deposit that was just posted along with it -- every Cheque/DD approval that ever + // hit an edge case in the new hold-placement logic would fail entirely, where before + // this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly + // best-effort: the deposit approval this whole endpoint exists for must never depend on + // it, only benefit from it when it works. + try { + placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr); + } catch (final RuntimeException e) { + log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id, + savingsAccountId, e.getMessage(), e); + } + } } // Link voucher to the created savings txn and stamp voucher_no on it. @@ -586,6 +601,54 @@ public class CashierDepositRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } + /** + * A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the + * branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount + * until the instrument clears rather than let it sit withdrawable, so this places an automatic + * Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and + * electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the + * spot, and an electronic transfer has already settled by the time it's entered here. + */ + private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount, + final String txnDateStr) { + if (paymentTypeId == null) { + return; + } + final Long ptId = ((Number) paymentTypeId).longValue(); + // m_payment_type's display name column is "value", not "name" (the mistake that broke this + // in testing) -- name() collisions with other tables made this easy to get wrong once and not + // notice until it actually ran against the real schema. + final List names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?", + (rs, rowNum) -> rs.getString("value"), ptId); + if (names.isEmpty()) { + return; + } + final String name = names.get(0).toLowerCase(); + final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd") + || name.contains("demand draft"); + if (!isInstrumentDeposit) { + return; + } + + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit"); + payload.addProperty("holdCategory", "CLEARING"); + payload.addProperty("lienAllowed", false); + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .holdAmount(savingsAccountId) + .withJson(payload.toString()) + .build(); + commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + private String executeCurrentAccountDeposit( final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { @@ -699,7 +762,7 @@ public class CashierDepositRequestApiResource { // approve() updates txn_id to real Fineract id; reject() deletes these records. private void savePendingDenomination(final String denominationData, final Long requestId, final Long savingsAccountId, final String accountNo, - final Long clientId, final Long cashierId, final String cashierUsername) { + final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) { final JsonObject denomMap; try { denomMap = gson.fromJson(denominationData, JsonObject.class); @@ -729,12 +792,12 @@ public class CashierDepositRequestApiResource { jdbcTemplate.update(""" INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?) + denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?) """, requestId, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), - cashierId, cashierUsername + cashierId, cashierUsername, paymentTypeName ); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index ec99ad3c3..a277822a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -126,8 +126,13 @@ public class CashReportApiResource { filtered.add(row); // Only approved money is real money: a pending or rejected voucher must not inflate the - // day's totals, even though it still belongs in the listing. - if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) { + // day's totals, even though it still belongs in the listing. The three source registers + // don't share one terminal status string: deposits and disbursements land on APPROVED, + // but a clerk withdrawal's own finished state is WITHDRAWN -- missing it here meant a + // completed withdrawal moved real money out yet never showed up in the DR total at all. + final String rowStatus = (String) row.get("status"); + if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + || "WITHDRAWN".equalsIgnoreCase(rowStatus)) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { if ("CR".equals(row.get("side"))) { @@ -894,9 +899,14 @@ public class CashReportApiResource { baseParams = new Object[] { txnDate }; } - // Base grouped query (reused for count, totals, and paged data) + // Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve + // the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/ + // withdrawal request at approval time -- std.txn_id on its own is only the internal savings + // transaction id (e.g. "4"), never what was actually printed on the voucher. final String baseGroupSql = "FROM m_savings_txn_denomination std " + "LEFT JOIN m_client mc ON mc.id = std.client_id " + + "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + + "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; @@ -917,7 +927,7 @@ public class CashReportApiResource { // 2. Paged data query final String dataSql = "SELECT " - + " std.txn_id AS voucher_no, " + + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " std.account_no AS ac_no, " + " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, " + " MAX(std.txn_date) AS txn_date, " @@ -939,7 +949,7 @@ public class CashReportApiResource { final List> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> { final Map row = new LinkedHashMap<>(); - row.put("voucherNo", rs.getLong("voucher_no")); + row.put("voucherNo", rs.getString("voucher_no")); row.put("acNo", rs.getString("ac_no")); row.put("acName", rs.getString("ac_name")); row.put("txnDate", rs.getString("txn_date")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 736480cb9..f490750f3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -663,6 +663,7 @@ public class ClerkWithdrawalRequestApiResource { final String accountNo = (String) record.get("account_no"); final Object clientIdObj = record.get("client_id"); final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null; + final String paymentTypeName = (String) record.get("payment_type_name"); for (final Map.Entry entry : denomMap.entrySet()) { final int count; @@ -686,12 +687,12 @@ public class ClerkWithdrawalRequestApiResource { jdbcTemplate.update(""" INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, - denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?) + denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?) """, txnId, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), - cashier.getId(), cashier.getUsername() + cashier.getId(), cashier.getUsername(), paymentTypeName ); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java index 1dec7cda0..d54ba01c4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java @@ -738,7 +738,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId()); account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper); if (item.isHoldPlaced() && item.getHoldAmount() != null) { - account.releaseOnHoldAmount(item.getHoldAmount()); + account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING); } final Office office = account.office(); final Money amountMoney = Money.of(account.getCurrency(), amount); @@ -770,7 +770,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat } if (isSavings(item)) { final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId()); - account.releaseOnHoldAmount(item.getHoldAmount()); + account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING); this.savingsAccountRepositoryWrapper.saveAndFlush(account); } else { final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/InwardClearingValidationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/InwardClearingValidationService.java index 032aeeb15..098a536c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/InwardClearingValidationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/InwardClearingValidationService.java @@ -284,7 +284,10 @@ public class InwardClearingValidationService { if (account == null) { return; } - account.holdAmount(amount); + // A presented-but-unsettled cheque is exactly what "Clearing Hold" means -- money in the + // clearing pipeline, not a staff-placed lien -- so it shares the bucket with a pending + // cheque/DD deposit rather than the generic manual Cash Hold. + account.holdAmount(amount, SavingsAccount.HOLD_CATEGORY_CLEARING); this.savingsAccountRepository.saveAndFlush(account); } else { final CurrentAccount account = this.currentAccountRepository.findById(item.getAccountId()).orElse(null); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java index a53e5eceb..cc5e5542f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionDataValidator.java @@ -69,7 +69,8 @@ public class SavingsAccountTransactionDataValidator { private final FromJsonHelper fromApiJsonHelper; private static final Set SAVINGS_ACCOUNT_HOLD_AMOUNT_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(transactionDateParamName, SavingsApiConstants.dateFormatParamName, SavingsApiConstants.localeParamName, - transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName)); + transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName, + SavingsApiConstants.holdCategoryParamName)); private final ConfigurationDomainService configurationDomainService; public void validateTransactionWithPivotDate(final LocalDate transactionDate, final SavingsAccount savingsAccount) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 10573774a..5ea7fe588 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -120,7 +120,6 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException; import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException.PostInterestAsOnExceptionType; -import org.apache.fineract.portfolio.savings.exception.PostInterestClosingDateException; import org.apache.fineract.portfolio.savings.exception.SavingsAccountClosingNotAllowedException; import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentException; @@ -955,22 +954,26 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi final Locale locale = command.extractLocale(); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); - final boolean isPostInterest = command.booleanPrimitiveValueOfParameterNamed(SavingsApiConstants.postInterestValidationOnClosure); - // postInterest(account,closedDate,flag); - if (isPostInterest) { - boolean postInterestOnClosingDate = false; - List savingTransactions = account.getTransactions(); - for (SavingsAccountTransaction savingTransaction : savingTransactions) { - if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed() - && DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) { - postInterestOnClosingDate = true; - break; - } - } - if (!postInterestOnClosingDate) { - throw new PostInterestClosingDateException(); + + // Pro-rata interest for whatever partial period has accrued since the account's last posting is + // calculated and posted here, unconditionally -- closing used to only ever *validate* that + // interest had already been posted on the closing date (gated behind + // postInterestValidationOnClosure, off by default), so with nothing manually posted beforehand + // the partial period's interest was simply never paid. postInterest(...) already no-ops for a + // zero-interest product (its own rate>0 guard) and for a period with nothing newly due, so + // calling it here on every closure is safe -- it only ever adds the posting that closing an + // account on a real business day should always have included. + boolean interestAlreadyPostedOnClosingDate = false; + for (SavingsAccountTransaction savingTransaction : account.getTransactions()) { + if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed() + && DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) { + interestAlreadyPostedOnClosingDate = true; + break; } } + if (!interestAlreadyPostedOnClosingDate) { + this.postInterest(account, true, closedDate, false); + } final Map changes = new LinkedHashMap<>(); @@ -1835,7 +1838,14 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.savingsAccountTransactionDataValidator.validateHoldAndAssembleForm(command.json(), account, submittedBy, backdatedTxnsAllowedTill); SavingsAccountTransaction transaction = this.savingsAccountDomainService.handleHold(account, amount, transactionDate, lienAllowed); - account.holdAmount(amount); + // Defaults to Cash Hold (a manual/staff-placed lien) -- the automatic clearing hold placed on a + // Cheque/DD deposit at approval time is the only caller expected to pass CLEARING explicitly. + final String requestedHoldCategory = command.stringValueOfParameterNamed(SavingsApiConstants.holdCategoryParamName); + final String holdCategory = SavingsAccount.HOLD_CATEGORY_CLEARING.equals(requestedHoldCategory) + ? SavingsAccount.HOLD_CATEGORY_CLEARING + : SavingsAccount.HOLD_CATEGORY_CASH; + account.holdAmount(amount, holdCategory); + transaction.updateHoldCategory(holdCategory); transaction.setRunningBalance(runningBalance); final String reasonForBlock = command.stringValueOfParameterNamed(SavingsApiConstants.reasonForBlockParamName); @@ -1883,7 +1893,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi transaction.setRunningBalance(runningBalance); this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transaction.getTransactionDate(), account); - account.releaseOnHoldAmount(transaction.getAmount()); + // Credit back whichever bucket the original hold debited -- a Clearing Hold released here (the + // manual "release early" action) must not accidentally credit the Cash Hold total instead. + final String holdCategory = holdTransaction.getHoldCategory() != null ? holdTransaction.getHoldCategory() + : SavingsAccount.HOLD_CATEGORY_CASH; + account.releaseOnHoldAmount(transaction.getAmount(), holdCategory); this.savingsAccountTransactionRepository.saveAndFlush(transaction); holdTransaction.updateReleaseId(transaction.getId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsClearingHoldReleaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsClearingHoldReleaseApiResource.java new file mode 100644 index 000000000..9aaa5501b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsClearingHoldReleaseApiResource.java @@ -0,0 +1,77 @@ +package org.apache.fineract.portfolio.savingshold.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +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.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Auto-releases a Clearing Hold once its instrument's clearing period has passed — real-world CBS + * practice for a cheque/DD deposit that was held pending clearance (see + * {@code CashierDepositRequestApiResource.placeClearingHoldIfInstrumentDeposit}). This is a plain, + * self-contained REST trigger rather than a job registered in Fineract's Spring Batch scheduler: the + * batch job framework is a deep, unfamiliar subsystem to extend correctly, and a wrong registration + * there risks the whole scheduler, not just this feature. Wire this endpoint into an external + * scheduler (cron / the existing OS-level task runner) to run it daily; a manager can also call it + * directly, or release one hold early and by hand via the existing releaseAmount action, for an + * instrument confirmed cleared sooner than the default window. + */ +@Path("/v1/savingsaccounts/clearingholds/release-due") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsClearingHoldReleaseApiResource { + + /** Kept as a simple constant rather than a new admin-editable setting -- the clearing window this + * branch actually needs can be tuned here once real usage shows what it should be. */ + private static final int CLEARING_HOLD_DEFAULT_DAYS = 3; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final Gson gson = new Gson(); + + @POST + public String releaseDue() { + this.context.authenticatedUser(); + + final LocalDate cutoff = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).minusDays(CLEARING_HOLD_DEFAULT_DAYS); + + final List> due = this.jdbcTemplate.queryForList(""" + SELECT id, savings_account_id FROM m_savings_account_transaction + WHERE hold_category = 'CLEARING' AND release_id_of_hold_amount IS NULL + AND is_reversed = false AND transaction_date <= ? + """, java.sql.Date.valueOf(cutoff)); + + final List> released = new ArrayList<>(); + final List> failed = new ArrayList<>(); + for (final Map row : due) { + final Long savingsId = ((Number) row.get("savings_account_id")).longValue(); + final Long transactionId = ((Number) row.get("id")).longValue(); + try { + final CommandWrapper commandRequest = new CommandWrapperBuilder().releaseAmount(savingsId, transactionId) + .withJson("{}").build(); + this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + released.add(Map.of("savingsId", savingsId, "transactionId", transactionId)); + } catch (final RuntimeException e) { + failed.add(Map.of("savingsId", savingsId, "transactionId", transactionId, "error", String.valueOf(e.getMessage()))); + } + } + + return this.gson.toJson( + Map.of("cutoffDate", cutoff.toString(), "releasedCount", released.size(), "released", released, "failed", failed)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsHoldBreakdownApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsHoldBreakdownApiResource.java new file mode 100644 index 000000000..ef91580b1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savingshold/api/SavingsHoldBreakdownApiResource.java @@ -0,0 +1,49 @@ +package org.apache.fineract.portfolio.savingshold.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The two-way hold breakdown behind a savings account's single combined "amount on hold" figure — + * a manual, staff-placed lien (Cash Hold) versus money still moving through the clearing process, a + * cheque/DD deposit not yet realized or a presented cheque not yet settled (Clearing Hold). + * + * Kept as a narrow, additive endpoint alongside the standard Fineract savings-account read API rather + * than adding a field to {@code SavingsAccountData} — that class has roughly a dozen constructor + * overloads threading its existing fields through every savings API response, and touching all of them + * for one screen's two extra numbers would be a wide, easy-to-regress change for a small addition. + */ +@Path("/v1/savingsaccounts/{savingsId}/holdbreakdown") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsHoldBreakdownApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + @GET + public String retrieve(@PathParam("savingsId") final Long savingsId) { + this.context.authenticatedUser(); + + final Map row = this.jdbcTemplate.queryForMap(""" + SELECT COALESCE(total_savings_amount_on_hold, 0) AS cash_hold, + COALESCE(total_savings_amount_on_hold_clearing, 0) AS clearing_hold + FROM m_savings_account WHERE id = ? + """, savingsId); + + return this.gson.toJson(Map.of("savingsId", savingsId, "cashHold", (BigDecimal) row.get("cash_hold"), + "clearingHold", (BigDecimal) row.get("clearing_hold"))); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index fff9dac05..ed25176f2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -39,4 +39,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_savings_account_clearing_hold.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_savings_account_clearing_hold.xml new file mode 100644 index 000000000..1ebb76e6f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_savings_account_clearing_hold.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_grant_checker_roles_hold_release_amount.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_grant_checker_roles_hold_release_amount.xml new file mode 100644 index 000000000..4068ebf3e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_grant_checker_roles_hold_release_amount.xml @@ -0,0 +1,31 @@ + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('HOLDAMOUNT_SAVINGSACCOUNT', 'RELEASEAMOUNT_SAVINGSACCOUNT') + AND r.name IN ('Branch Manager', 'As Manager', 'CASHIER', 'Principal Officer') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_grant_teller_roles_read_institution_branch_profile.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_grant_teller_roles_read_institution_branch_profile.xml new file mode 100644 index 000000000..b1ceceb41 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_grant_teller_roles_read_institution_branch_profile.xml @@ -0,0 +1,30 @@ + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_INSTITUTIONPROFILE', 'READ_BRANCHPROFILE') + AND r.name IN ('CASHIER', 'Clerk', 'Teller', 'Branch Manager', 'Principal Officer', 'As Manager') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 9a6d1b2b6..414134b27 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -349,6 +349,14 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom @Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true) private BigDecimal savingsOnHoldAmount; + + /** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet + * realized, or a presented cheque not yet settled) -- kept as its own running total, separate from + * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures + * rather than one bucket doing double duty. */ + @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) + private BigDecimal savingsOnHoldAmountClearing; + @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) protected List identifiers = new ArrayList<>(); @@ -3852,7 +3860,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom public BigDecimal getWithdrawableBalance() { return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) - .subtract(this.getSavingsHoldAmount()); + .subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing()); } public BigDecimal getWithdrawableBalanceWithLien() { @@ -4189,16 +4197,44 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return lastransactionDate; } + /** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a + * {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather + * than a decision someone made about this specific account. */ + public static final String HOLD_CATEGORY_CASH = "CASH"; + public static final String HOLD_CATEGORY_CLEARING = "CLEARING"; + public BigDecimal getSavingsHoldAmount() { return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount; } + public BigDecimal getSavingsHoldAmountClearing() { + return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing; + } + + /** Defaults to the manual-lien bucket -- every call site written before the two-category split + * keeps behaving exactly as it did before. */ public void holdAmount(BigDecimal amount) { - this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); + holdAmount(amount, HOLD_CATEGORY_CASH); + } + + public void holdAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); + } } public void releaseOnHoldAmount(BigDecimal amount) { - this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); + releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH); + } + + public void releaseOnHoldAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); + } } public AccountType getAccountType() { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java index 791a126aa..5724c699f 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountTransaction.java @@ -126,6 +126,12 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat @Column(name = "reason_for_block", nullable = true) private String reasonForBlock; + /** {@link SavingsAccount#HOLD_CATEGORY_CASH} or {@link SavingsAccount#HOLD_CATEGORY_CLEARING} -- + * only meaningful on a hold-amount transaction; carried here (rather than looked up) so a later + * release can credit the same running total the hold itself debited. */ + @Column(name = "hold_category", length = 20, nullable = true) + private String holdCategory; + @Column(name = "is_reversal", nullable = false) private boolean reversalTransaction; @@ -459,6 +465,14 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat this.reasonForBlock = reasonForBlock; } + public String getHoldCategory() { + return this.holdCategory; + } + + public void updateHoldCategory(String holdCategory) { + this.holdCategory = holdCategory; + } + public Long getReleaseIdOfHoldAmountTransaction() { return this.releaseIdOfHoldAmountTransaction; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java index 6eed63afb..a8a15d08d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/FacilityChargeSeederService.java @@ -72,9 +72,6 @@ public class FacilityChargeSeederService { @Transactional public void seedFacilityCharges() { try { - // Find an income GL account to link charges (prefer DETAIL type income account) - Long incomeGlAccountId = findIncomeGlAccountId(); - for (Map.Entry entry : FACILITY_DEFAULTS.entrySet()) { final String facilityType = entry.getKey(); final Object[] def = entry.getValue(); @@ -90,12 +87,21 @@ public class FacilityChargeSeederService { Long.class, chargeName); if (chargeIds.isEmpty()) { + // income_or_liability_account_id deliberately left NULL: Fineract's own + // accounting engine (AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges) + // uses a charge's own GL account, when set, ahead of the savings PRODUCT's own + // "Income From Fees" mapping. Setting one here would silently override every + // product's own configuration with the same shared account for all 16 facilities + // -- exactly what happened before this fix, all landing on whichever income + // account findIncomeGlAccountId() happened to find first. Leaving it unset lets + // each product's own accounting setup decide where this income posts, which is + // the actual point of a per-product "Income From Fees" mapping. jdbcTemplate.update( "INSERT INTO m_charge (name, currency_code, amount, charge_applies_to_enum, " + "charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, " - + "is_free_withdrawal, is_payment_type, income_or_liability_account_id) " - + "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false, ?)", - chargeName, amount, chargeAppliesTo, chargeTimeType, incomeGlAccountId); + + "is_free_withdrawal, is_payment_type) " + + "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false)", + chargeName, amount, chargeAppliesTo, chargeTimeType); log.info("FacilityChargeSeeder: inserted charge '{}'", chargeName); } @@ -122,24 +128,4 @@ public class FacilityChargeSeederService { log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage()); } } - - private Long findIncomeGlAccountId() { - try { - // Prefer "INCOME FROM ALL" GL account for facility charges - List ids = jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 " - + "AND disabled = false AND name = 'INCOME FROM ALL' LIMIT 1", - Long.class); - if (!ids.isEmpty()) return ids.get(0); - - // Fallback: first available income detail account - ids = jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 " - + "AND disabled = false ORDER BY id ASC LIMIT 1", - Long.class); - return ids.isEmpty() ? null : ids.get(0); - } catch (Exception e) { - return null; - } - } } From 29677e610c4a7c2519b5d80dcfd50c580b310fa5 Mon Sep 17 00:00:00 2001 From: keshav Date: Sun, 30 Aug 2026 11:10:42 +0530 Subject: [PATCH 19/55] done --- ...WritePlatformServiceJpaRepositoryImpl.java | 65 ++- .../AccountingJournalEntryConfiguration.java | 6 +- .../api/DistributableProfitApiResource.java | 50 +- .../api/CashierDepositRequestApiResource.java | 72 ++- .../ShareAdvanceCollectionApiResource.java | 2 +- .../ChequeBookIssuanceChargeService.java | 56 ++- .../service/ClearingJournalService.java | 95 +++- .../ClerkDisbursementRequestApiResource.java | 108 ++++- .../ClerkWithdrawalRequestApiResource.java | 37 ++ .../CtsClearingWritePlatformServiceImpl.java | 2 +- .../api/DividendClaimRequestApiResource.java | 110 ++++- .../api/DividendDashboardApiResource.java | 154 +++++- ...WritePlatformServiceJpaRepositoryImpl.java | 71 +++ .../starter/LoanAccountConfiguration.java | 6 +- .../service/NachWritePlatformServiceImpl.java | 4 +- .../SavingsDailyInterestOrchestrator.java | 135 ++++++ .../SavingsDailyInterestService.java | 445 ++++++++++++++++++ .../SavingsInterestCycleCalculator.java | 146 ++++++ .../SavingsPooledInterestPostingService.java | 300 ++++++++++++ .../api/SavingsDailyInterestApiResource.java | 178 +++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 101 +++- .../savings/starter/SavingsConfiguration.java | 8 +- .../ShareAccountSchedularServiceImpl.java | 27 +- .../api/AllProductsDividendApiResource.java | 153 +++--- .../api/ShareCapitalReturnApiResource.java | 260 ++++++++++ ...hareDividendCapitalPreviewApiResource.java | 7 +- .../ShareProductDividendAssembler.java | 7 +- ...WritePlatformServiceJpaRepositoryImpl.java | 213 +++++---- ...DIssueRequestWritePlatformServiceImpl.java | 200 +++++++- .../api/VoucherVerificationApiResource.java | 223 ++++++++- .../voucher/service/VoucherLine.java | 95 ++++ .../voucher/service/VoucherService.java | 333 +++++++++++-- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 11 +- .../tenant/final-changelog-tenant.xml | 4 + ...0494_drop_dividend_forfeited_income_gl.xml | 63 +++ .../parts/0495_voucher_no_drop_branch.xml | 79 ++++ .../tenant/parts/0496_create_voucher_line.xml | 101 ++++ .../parts/0497_voucher_reference_key.xml | 45 ++ ...add_rate_percentage_to_dividend_payout.xml | 84 ++++ ...d_savings_interest_posting_control_gls.xml | 97 ++++ ...7_create_savings_daily_interest_ledger.xml | 239 ++++++++++ ...eate_savings_interest_posting_schedule.xml | 124 +++++ ...19_post_savings_interest_at_period_end.xml | 64 +++ 44 files changed, 4265 insertions(+), 319 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index dad040d9f..2f79ebae8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -104,6 +105,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +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.dao.DataIntegrityViolationException; import org.springframework.dao.NonTransientDataAccessException; @@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa private final ExternalAssetOwnerRepository externalAssetOwnerRepository; private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository; private final LoanTransactionRepository loanTransactionRepository; + private final VoucherService voucherService; @Transactional @Override @@ -232,14 +236,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa } + // Raise the voucher from the entries that were actually written, rather than from the + // command: an accounting rule can supply a side the request never named, so reading + // back by transaction id is the only view that matches what the ledger now holds. + final String voucherNo = raiseJournalVoucher(transactionId); + if (voucherNo != null) { + changes.put("voucherNo", voucherNo); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId) - .withTransactionId(transactionId).build(); + .withTransactionId(transactionId).with(changes).build(); } catch (final JpaSystemException | DataIntegrityViolationException dve) { final Throwable throwable = dve.getMostSpecificCause(); throw handleJournalEntryDataIntegrityIssues(throwable, dve); } } + /** + * Records a manual journal in the voucher register and returns its number. + * + * The sides are read back from the entries just written rather than taken from the request, + * because an accounting rule can contribute a debit or credit the caller never named; only the + * persisted rows show what the ledger actually holds. + * + * A failure here does not fail the journal. The entries are already posted and correct, and + * refusing them because the register could not be written would reject a sound accounting + * transaction over its paperwork. The problem is logged and the journal stands without a + * voucher, which reads as a gap in the register rather than as a missing ledger entry. + */ + private String raiseJournalVoucher(final String transactionId) { + // A register that posts its ledger movement through this command raises its own voucher, + // naming the member and the instrument. Raising a journal one beside it would put the same + // money in the register twice. + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final List entries = this.glJournalEntryRepository + .findUnReversedManualJournalEntriesByTransactionId(transactionId); + if (CollectionUtils.isEmpty(entries)) { + return null; + } + + final List lines = new ArrayList<>(); + for (final JournalEntry entry : entries) { + final GLAccount account = entry.getGlAccount(); + final Long accountId = account == null ? null : account.getId(); + final String name = account == null ? null : account.getName(); + final String code = account == null ? null : account.getGlCode(); + + final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount()) + : VoucherLine.creditGl(accountId, name, code, entry.getAmount()); + lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription())); + } + + // Debits first, so the voucher prints the way a slip is read. + lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed()); + + final AppUser currentUser = this.context.authenticatedUser(); + return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null, + transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for journal transaction {}", transactionId, e); + return null; + } + } + private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount, final SingleDebitOrCreditEntryCommand[] creditOrDebits) { /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java index 57c1a1f2a..70df94911 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java @@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration { ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService, ExternalAssetOwnerRepository externalAssetOwnerRepository, LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository, - LoanTransactionRepository loanTransactionRepository) { + LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) { return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository, officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory, accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository, glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService, financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService, - accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository); + accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository, + voucherService); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java index 1e03e915e..afb530e25 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java @@ -41,19 +41,21 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; /** - * How much profit a share dividend may actually be declared out of, for a given period. + * How much profit is available to fund a share dividend, for a given period. * - * A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve - * Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared - * for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one - * place so the declaration screen and the declaration-time validation cannot disagree about the ceiling. + * This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has + * to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared + * for another share product over the same period, since they all draw on the same balance. * - * Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration - * row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error. + * The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution + * screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a + * second time would hold back money that has already left the account. + * + * Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling. */ @Path("/v1/accounting/distributable-profit") @Component -@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.") +@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.") @RequiredArgsConstructor public class DistributableProfitApiResource { @@ -66,8 +68,6 @@ public class DistributableProfitApiResource { */ private static final String PROFIT_GL_CODE = "BANKPROFITND01"; - private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; - private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage"; private final JdbcTemplate jdbcTemplate; @@ -103,19 +103,14 @@ public class DistributableProfitApiResource { final boolean yearClosed = closedProfit != null; final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to); - final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); - final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE); - // Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate - // and nothing to distribute, so the whole waterfall collapses to zero rather than producing - // a negative "reserve" that would read as money owed back to the society. - final boolean profitable = netProfit.signum() > 0; - final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO; - final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO; - - final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount) - : BigDecimal.ZERO; + // The statutory reserve and education fund are NOT deducted here. They are appropriated on + // the profit-distribution screen before any dividend is declared, so the profit this reads + // has already had them taken out — subtracting them again would hold back money that is no + // longer in the account. A loss-making period funds nothing, so it collapses to zero rather + // than a negative figure a caller could read as a credit. + final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO; final List> existingDeclarations = declarationsOverlapping(from, to, excludeProductId); BigDecimal alreadyDeclared = BigDecimal.ZERO; @@ -137,10 +132,6 @@ public class DistributableProfitApiResource { // that is still moving — rather than showing a bare number the officer has to interpret. response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS"); response.put("netProfit", netProfit); - response.put("statutoryReservePercentage", statutoryReservePercentage); - response.put("statutoryReserveAmount", statutoryReserveAmount); - response.put("educationFundPercentage", educationFundPercentage); - response.put("educationFundAmount", educationFundAmount); response.put("distributableProfit", distributableProfit); response.put("alreadyDeclared", alreadyDeclared); response.put("availableForDeclaration", availableForDeclaration); @@ -217,9 +208,8 @@ public class DistributableProfitApiResource { } /** - * Every dividend already declared against a period overlapping this one, across ALL share products — the - * appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable - * profit as the first. + * Every dividend already declared against a period overlapping this one, across ALL share products — the profit is + * society-wide, so a second product's declaration competes for the same balance as the first. * * excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping * declaration is separately blocked outright. @@ -267,10 +257,6 @@ public class DistributableProfitApiResource { return new BigDecimal(String.valueOf(rows.get(0).get("value"))); } - private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) { - return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); - } - private WebApplicationException apiError(final int httpStatus, final String message) { return new WebApplicationException( Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 17115fd52..ccf2b7033 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -70,6 +73,10 @@ import com.google.common.base.Splitter; @lombok.extern.slf4j.Slf4j public class CashierDepositRequestApiResource { + // Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the + // activity JsonArray, and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -421,6 +428,10 @@ public class CashierDepositRequestApiResource { } } + // The sides of the slip, recorded now rather than at submission: which accounts the money + // actually moved between is only settled once the request is approved and posted. + addDepositVoucherLines(record, savingsAccountId, amount); + final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null ); @@ -555,6 +566,62 @@ public class CashierDepositRequestApiResource { // ── helpers ────────────────────────────────────────────────────────────────── + /** + * Records the two sides of an approved deposit on its voucher. + * + * A deposit is money arriving at the counter, so cash is debited and whatever receives it is + * credited — a member's savings account, or a GL account when the deposit was raised against + * one. The credited side is read from the request, because that is what the cashier actually + * filled in and what the member signed for. + * + * A failure here does not fail the approval. The deposit is already posted and the member's + * balance already raised; refusing that over its slip would take a correct transaction back for + * a paperwork reason. The problem is logged, and the voucher keeps its number without sides. + */ + private void addDepositVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + + try { + final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type")); + final String accountNo = (String) record.get("account_no"); + + // Cash reaching the counter. For a GL deposit the contra is the cash/vault account the + // request named; for a member deposit it is the cashier's own till, which the deposit + // register does not carry a GL id for — the line still names it, without an id. + final Object contraObj = record.get("contra_gl_id"); + final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null; + final Map contra = glAccountForVoucher(contraGlId); + final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"), + (String) contra.get("gl_code"), amount); + + final VoucherLine credit; + if (isGl) { + final Object glObj = record.get("gl_account_id"); + final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null; + final Map gl = glAccountForVoucher(glAccountId); + credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount); + } else { + credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount); + } + + voucherService.addLines(voucherNo, List.of(debit, credit)); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e); + } + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + private Map glAccountForVoucher(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, account_no, account_type, savings_account_id, @@ -748,7 +815,10 @@ public class CashierDepositRequestApiResource { .withJson(payload.toString()) .build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // The deposit register raises its own voucher, naming the member and their account; the + // journal command must not raise a second one over the same money. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getTransactionId() != null ? result.getTransactionId() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 4db4f4227..8a9de15f2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -206,7 +206,7 @@ public class ShareAdvanceCollectionApiResource { // ══════════════════════════════════════════════════════════════════════ // GET — One unapplied receipt by the voucher number quoted by the member // ══════════════════════════════════════════════════════════════════════ - // The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001: + // The voucher arrives as a query parameter because numbers are formatted CR/2108/0001: // as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict // firewall with a 400 before the request reaches this resource. The older // /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index d13f29caf..e7e9ddcff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.charge.domain.Charge; @@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService { private final FineractRequestContextHolder fineractRequestContextHolder; private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; private final SavingsHelper savingsHelper; + private final PlatformSecurityContext context; + private final VoucherService voucherService; + + /** A cheque book issuance charge, keyed by its m_cheque_book id. */ + private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE"; + /** The configured charge, if the admin has set one up — absent means "no charge configured yet." */ private FacilityChargeMapping resolveMapping() { @@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService { postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, accountId); + raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); } @@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService { this.currentAccountRepository.saveAndFlush(account); } + /** + * Records a cheque book issuance charge in the voucher register. + * + * The account is debited and the charge's income account credited — the society's fee, taken + * from the customer. The debit names the customer and their account number even though it posts + * against the product's control GL, so the slip reads as the customer's own. + * + * A failure here does not fail the charge. The money is already taken and the ledger already + * posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The + * problem is logged and the charge stands. + */ + private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId, + final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) { + try { + final Map holder = accountHolderForVoucher(accountType, accountId); + final Map incomeGl = this.voucherService.glAccount(incomeGlId); + final String narration = "Cheque book issuance charge - book #" + chequeBookId; + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId, + this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT, + List.of(VoucherLine + .debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount) + .withDescription(narration), + VoucherLine + .creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount) + .withDescription(narration))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e); + } + } + + /** The customer and account number a charge is taken from, for printing on a voucher. */ + private Map accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) { + final String sql = accountType == ChequeAccountType.SAVINGS + ? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa " + + "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?" + : "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca " + + "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?"; + return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); + } + /** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */ private Long getSavingsControlGlId(final Long savingsAccountId) { final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa " @@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService { final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The charge raises its own voucher, naming the account charged and the fee; the journal + // command must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); if (result == null) { log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId); return; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index ffae689a8..852f9124e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -24,6 +24,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; @@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -49,6 +53,11 @@ public class ClearingJournalService { private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final FineractRequestContextHolder fineractRequestContextHolder; private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final VoucherService voucherService; + + /** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */ + private static final String REF_CLEARING_POSTING = "CLEARING_POSTING"; /** * Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null @@ -57,6 +66,19 @@ public class ClearingJournalService { */ public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) { + return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null); + } + + /** + * As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT. + * + * Savings and current accounts number independently, so the same id exists in both tables and the id + * alone cannot say whose account it is. A caller that knows the type passes it and the voucher names + * the right customer; one that has no account at all — a settlement summary — passes neither. + */ + public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, + final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId, + final String linkAccountType) { if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) { log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId, creditGlId, amount); @@ -86,7 +108,10 @@ public class ClearingJournalService { final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // Clearing raises its own voucher below, naming the customer account the item belongs to; + // the journal command must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); if (result == null) { log.warn("Clearing journal entry did not return a result ({}).", comments); return null; @@ -95,9 +120,77 @@ public class ClearingJournalService { if (glTxnId != null && linkAccountId != null) { linkDebitEntryToAccount(glTxnId, linkAccountId); } + raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; } + /** + * Records one clearing posting in the voucher register. + * + * Raised here rather than in each caller because CTS items, NACH movements and settlement + * summaries all post through this one path: a voucher per caller would be the same slip written + * three times, and one of them would drift. + * + * The caller's comment carries what the posting is — the item, the movement, the batch — so it + * becomes the narration on both sides. Where the caller named a customer account, the debit line + * carries that customer and account number; a settlement summary names no account and its lines + * stand on the GL names alone, which is what a summary is. + * + * A failure here does not fail the posting. The entry is already in the ledger, and clearing runs + * over batches where one unwritable slip must not take a whole settlement down with it. The + * problem is logged and the posting stands. + */ + private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount, + final String comments, final Long linkAccountId, final String linkAccountType) { + if (glTxnId == null) { + return; + } + try { + final Map debitGl = this.voucherService.glAccount(debitGlId); + final Map creditGl = this.voucherService.glAccount(creditGlId); + final Map holder = accountHolderForVoucher(linkAccountId, linkAccountType); + + // The debit is the side a customer account is named against, matching the row this + // posting links to the account for reporting. + final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name"); + final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no") + : (String) debitGl.get("gl_code"); + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId, + this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL, + List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments), + VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount) + .withDescription(comments))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e); + } + } + + /** + * The customer and account number a clearing item belongs to, for printing on a voucher. + * + * The type decides which table to read: savings and current accounts number independently, so + * the same id exists in both and looking in the wrong one would put another customer's name on + * the slip. Empty when the caller named no account, as a settlement summary does, or named one + * without saying which kind — a wrong name is worse on a voucher than no name. + */ + private Map accountHolderForVoucher(final Long accountId, final String accountType) { + if (accountId == null || accountType == null) { + return Map.of(); + } + final String sql; + if ("SAVINGS".equalsIgnoreCase(accountType)) { + sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa " + + "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"; + } else if ("CURRENT".equalsIgnoreCase(accountType)) { + sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca " + + "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?"; + } else { + return Map.of(); + } + return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); + } + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { final List rowIds = this.jdbcTemplate.queryForList( "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index b2ad10e9f..626f6e5c0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -46,6 +46,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -65,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +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.jdbc.core.JdbcTemplate; @@ -719,6 +721,11 @@ public class ClerkDisbursementRequestApiResource { throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details."); } + // The sides of the slip, built from the entries as approved. Only reached once every + // posting above has succeeded, so the voucher describes a completed movement rather than + // one that was attempted. + addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType); + final String txnRefsStr = String.join(",", txnRefs); final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null); @@ -1004,7 +1011,7 @@ public class ClerkDisbursementRequestApiResource { /** * Bank-transfer settlement voucher, drawn from the same central register as every other * voucher so the two numbering schemes cannot drift apart. A settlement moves money out of - * the debited accounts, so it reads as a debit: DR/001/2108/0001. + * the debited accounts, so it reads as a debit: DR/2108/0001. */ private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { return voucherService.createVoucher( @@ -1042,6 +1049,97 @@ public class ClerkDisbursementRequestApiResource { ); } + /** + * Records the sides of an approved disbursement, collection or bank transfer on its voucher. + * + * The register covers three directions under one request type, and each puts the society's GL + * on a different side: + * + *
    + *
  • DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each + * account credited.
  • + *
  • COLLECTION — money into the target GL out of the listed accounts: each account debited, + * GL credited.
  • + *
  • BANK_TRANSFER — account to account with no GL involved at all; each entry already carries + * its own side, so they are used as given.
  • + *
+ * + * The society's side is written as a single line for the total rather than one per counterparty, + * because that is how it posts and how the slip reads. + * + * A failure here does not fail the approval: every transaction is posted by this point, and the + * money has moved. The problem is logged, and the voucher keeps its number without sides. + */ + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, + final String requestType) { + + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + + try { + final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType); + final boolean isCollection = "COLLECTION".equals(requestType); + + final List lines = new ArrayList<>(); + BigDecimal counterpartyTotal = BigDecimal.ZERO; + + for (final JsonElement el : creditEntries) { + final JsonObject entry = el.getAsJsonObject(); + final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString()); + final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null; + final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull() + ? entry.get("accountName").getAsString() + : accountNo; + final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + + final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull() + ? entry.get("glAccountId").getAsLong() + : null; + final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull() + ? entry.get("savingsAccountId").getAsLong() + : null; + + // A bank transfer states each side on the entry itself; the other two directions + // put every counterparty on the side opposite the society's GL. + final boolean entryIsDebit = isBankTransfer + ? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") + : isCollection; + + final VoucherLine line; + if (isGlEntry) { + line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount) + : VoucherLine.creditGl(glId, accountName, accountNo, entryAmount); + } else { + line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount) + : VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount); + } + lines.add(line); + + if (!isBankTransfer) { + counterpartyTotal = counterpartyTotal.add(entryAmount); + } + } + + // The society's own side. A bank transfer has none — the money never leaves the members + // between them — so its entries already balance on their own. + if (!isBankTransfer) { + final String glName = (String) record.get("gl_account_name"); + final String glNo = (String) record.get("gl_account_no"); + lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal) + : VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal)); + } + + // Debits first, so the voucher prints the way a slip is read. + lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed()); + + voucherService.addLines(voucherNo, lines); + } catch (final RuntimeException e) { + log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e); + } + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount, @@ -1226,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource { .withJson(payload.toString()) .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - commandsSourceWritePlatformService.logCommandSource(cmd); + // The disbursement register raises its own voucher; suppress the journal command's own. + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); } // ── Combined GL entry helpers ───────────────────────────────────────────── @@ -1663,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource { .withJson(payload.toString()) .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); + // The disbursement register raises its own voucher, naming every account involved; the + // journal command must not raise a second one over the same money. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); return result != null ? result.getTransactionId() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index f490750f3..8a5985e8b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -69,6 +72,10 @@ import com.google.common.base.Splitter; @RequiredArgsConstructor public class ClerkWithdrawalRequestApiResource { + // Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the + // activity JsonArray, and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -457,6 +464,10 @@ public class ClerkWithdrawalRequestApiResource { VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId)); } + // The sides of the slip. A withdrawal is money leaving the member, so their account is + // debited and the till that pays it out is credited. + addWithdrawalVoucherLines(record, savingsAccountId, amount); + final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null ); @@ -697,6 +708,32 @@ public class ClerkWithdrawalRequestApiResource { } } + /** + * Records the two sides of an approved withdrawal on its voucher. + * + * Money leaves the member and is paid out over the counter, so the member's account is debited + * and the till is credited. The withdrawal register does not carry a GL id for the till — it + * pays out of whichever cashier's drawer serves the request — so that side is named without one. + * + * A failure here does not fail the approval: the withdrawal is already posted and the member + * already paid. The problem is logged, and the voucher keeps its number without sides. + */ + private void addWithdrawalVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) { + return; + } + + try { + voucherService.addLines(voucherNo, + List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"), + (String) record.get("account_no"), amount), + VoucherLine.creditGl(null, "Cash in Hand", null, amount))); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e); + } + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java index d54ba01c4..b6b0b58c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ctsclearing/service/CtsClearingWritePlatformServiceImpl.java @@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat controlGlId, suspenseGlId); } else { this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount, - "CTS inward clearing pass - item #" + itemId, item.getAccountId()); + "CTS inward clearing pass - item #" + itemId, item.getAccountId(), item.getAccountType()); } markLeaf(item, true, "CTS cleared", null, userId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index fc2bad879..eb00771ee 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -44,6 +44,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -51,6 +52,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +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.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -81,15 +84,20 @@ import org.springframework.transaction.annotation.Transactional; @Component @Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.") @RequiredArgsConstructor +@Slf4j public class DividendClaimRequestApiResource { private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + + /** A dividend payout, keyed by its m_share_dividend_claim_request id. */ + private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST"; /** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */ private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; @@ -126,6 +134,9 @@ public class DividendClaimRequestApiResource { final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root)); final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root)); final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root)); + // Which physical leaflet the clerk took off the shelf. Optional: a caller that has not + // chosen one gets the oldest available instead. + final Long requestedLeafletId = fromJsonHelper.extractLongNamed("leafletId", root); if (lockId == null) { throw apiError(400, "lockId is required"); } @@ -153,15 +164,6 @@ public class DividendClaimRequestApiResource { throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft"); } - // A draft can only be raised if there is stock to raise it on. Checked here rather than at - // approval so the clerk finds out while the member is still at the counter, instead of the - // claim sitting pending and failing later. - final Long availableLeaflets = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class); - if (availableLeaflets == null || availableLeaflets == 0) { - throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."); - } - final Long existingPending = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId); if (existingPending != null && existingPending > 0) { @@ -170,19 +172,63 @@ public class DividendClaimRequestApiResource { validateNotGuarantorOfOverdueLoan(clientId); + // The leaflet is taken now rather than at approval, so the DD number exists from the moment + // the claim is raised and the draft has a place in the queue that nothing else can take. + // + // The clerk chooses which one on the leaflets screen, and that choice is honoured: the draft + // has to be drawn on the physical form in their hand, or the number written on the + // counterfoil is not the number in the books. Locked so two concurrent claims cannot take the + // same leaflet; if it was taken in the meantime the clerk is told rather than being given a + // different draft than the one they are holding. + final Map leaflet = requestedLeafletId != null + ? jdbcTemplate + .queryForList("SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ? AND status = 'AVAILABLE' " + + "FOR UPDATE SKIP LOCKED", requestedLeafletId) + .stream().findFirst() + .orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has " + + "just taken it. Go back and choose another leaflet.")) + // No leaflet named (a caller that is not the leaflets screen): fall back to the + // oldest available, which is the order a series is meant to be consumed in. + : jdbcTemplate.queryForList(""" + SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' + ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED + """).stream().findFirst().orElseThrow(() -> apiError(409, + "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.")); + final Long leafletId = ((Number) leaflet.get("id")).longValue(); + final String ddNumber = (String) leaflet.get("dd_number"); + final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); + // loan/savings/cash apply amounts are written as zero: those routes are retired, but the // columns remain for claims settled through them historically. final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_share_dividend_claim_request (lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount, dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city, - status, clerk_id, clerk_name, submitted_at) - VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?) + status, clerk_id, clerk_name, submitted_at, dd_number) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?) RETURNING id """, Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, - currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now())); + currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), ddNumber); - return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + // Raised as an ordinary PENDING issue request so the draft joins the DD Issuance Approvals + // queue with every other draft, rather than sitting in a second approvals screen of its own. + // payment_method 'DIVIDEND' is what tells that approval there is no purchaser to debit — + // the society already owes this money, so the draft is funded from Dividend Payable and the + // member pays nothing: no charge, no GST, which is why those columns are zero. + final Long issueRequestId = jdbcTemplate.queryForObject(""" + INSERT INTO m_sponsor_dd_issue_request + (leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city, + face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, + payment_method, status, maker_id, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'PENDING', ?, ?) + RETURNING id + """, Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity, + ddApplyAmount, branchId, java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), currentUser.getId(), + Timestamp.from(Instant.now())); + + jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET dd_issue_request_id = ? WHERE id = ?", issueRequestId, newId); + + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "ddNumber", ddNumber, "ddIssueRequestId", issueRequestId)); } /** @@ -471,13 +517,13 @@ public class DividendClaimRequestApiResource { // say how much of each year's declaration has actually been claimed. final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #" + claimId + " (lock #" + lockId + ")"; - if (dividendPaidAccountId == null) { // Dividend Paid not mapped: post straight across, exactly as before this account existed. // A society that has not configured it still gets a correct payout, just without the // separate "how much has been claimed" measure. postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description, currentUser); + raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser); return; } @@ -495,6 +541,42 @@ public class DividendClaimRequestApiResource { // Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed. postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description + " (draft raised)", currentUser); + + // The voucher names the two accounts the payout actually moves between, not the three the + // ledger touches. Dividend Paid nets to nil across the pair above: it is there to measure + // how much has been claimed, and putting it on the slip would show the member a middle + // account that holds none of their money. + raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser); + } + + /** + * Records the payout in the voucher register: Dividend Payable debited, the DD liability credited. + * + * A failure here does not fail the claim. The dividend is already posted and the draft already + * raised, and refusing an approved payout because its slip could not be written would hold back + * a member's money over paperwork. The claim stands, and the register shows a gap. + */ + private void raiseDividendVoucher(final Long claimId, final Long debitGlId, final Long creditGlId, final BigDecimal amount, + final String description, final AppUser currentUser) { + try { + final Map debitGl = glAccountForVoucher(debitGlId); + final Map creditGl = glAccountForVoucher(creditGlId); + + voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser, + VoucherService.PREFIX_DEBIT, + List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount) + .withDescription(description), + VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount) + .withDescription(description))); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for dividend claim {}", claimId, e); + } + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + private Map glAccountForVoucher(final Long glAccountId) { + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java index 43be84903..538799f5a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java @@ -26,6 +26,7 @@ import com.google.gson.JsonObject; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -127,6 +128,64 @@ public class DividendDashboardApiResource { return gson.toJson(result); } + // ── GET /v1/clerk/dividend-dashboard/declaration/{id}/report-header ────────── + + /** + * The heading of a Share Dividend Distribution Report: what rate the board resolved, what it came to, and when it + * was paid. + * + * The per-member breakdown screen already lists who gets what, but a distribution report is not just the list — it + * has to state the rate and the payment date, because that is what an AGM resolution and an auditor both ask for. + * Those two facts live on the declaration rather than on any member row, so they are fetched once here rather than + * repeated on every row of the member query. + * + * The rate is read as declared, never re-derived from amount over capital: a share movement back-dated into a + * closed period would silently change a rate the board actually resolved. + */ + @GET + @Path("/declaration/{dividendId}/report-header") + @Produces(MediaType.APPLICATION_JSON) + public String declarationReportHeader(@PathParam("dividendId") final Long dividendId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, p.name AS product_name, d.amount, d.rate_percentage, d.status, + d.dividend_period_start_date, d.dividend_period_end_date, + d.created_date, d.lastmodified_date, + COUNT(sad.id) FILTER (WHERE sad.amount > 0) AS paid_members, + COUNT(sad.id) FILTER (WHERE sad.amount = 0) AS unpaid_members, + COALESCE(SUM(sad.amount), 0) AS allocated_amount + FROM m_share_product_dividend_pay_out d + JOIN m_share_product p ON p.id = d.product_id + LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = d.id + WHERE d.id = ? + GROUP BY d.id, p.name + """, dividendId); + if (rows.isEmpty()) { + throw new NotFoundException("Dividend declaration not found: " + dividendId); + } + final Map row = rows.get(0); + final boolean approved = ((Number) row.get("status")).intValue() == DIVIDEND_PAYOUT_STATUS_APPROVED; + + final JsonObject out = new JsonObject(); + out.addProperty("dividendId", ((Number) row.get("id")).longValue()); + out.addProperty("productName", (String) row.get("product_name")); + final Object periodEnd = row.get("dividend_period_end_date"); + out.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + out.addProperty("periodEndDate", String.valueOf(periodEnd)); + out.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd))); + out.addProperty("ratePercentage", (BigDecimal) row.get("rate_percentage")); + out.addProperty("declaredAmount", (BigDecimal) row.get("amount")); + out.addProperty("allocatedAmount", (BigDecimal) row.get("allocated_amount")); + out.addProperty("paidMembers", ((Number) row.get("paid_members")).longValue()); + out.addProperty("unpaidMembers", ((Number) row.get("unpaid_members")).longValue()); + out.addProperty("status", approved ? "APPROVED" : "PENDING_APPROVAL"); + out.addProperty("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date"))); + // The payment date IS the approval date: approval is what makes a member's entitlement + // claimable, so nothing is payable before it and it is the date the report must quote. + out.addProperty("paymentDate", + approved && row.get("lastmodified_date") != null ? String.valueOf(row.get("lastmodified_date")) : null); + return gson.toJson(out); + } + // ── GET /v1/clerk/dividend-dashboard/declarations ──────────────────────────── /** @@ -176,6 +235,76 @@ public class DividendDashboardApiResource { // ── GET /v1/clerk/dividend-dashboard/locks ─────────────────────────────────── + /** + * One member's dividend record across every financial year — what they were entitled to, what they collected, what + * they let lapse, and on which draft. + * + * The lock history answers "what is outstanding right now"; this answers "what has this member ever been paid", + * which is the question at the counter when someone asks about a dividend from years ago. Forfeited years are + * included deliberately: a member asking why they never received a dividend needs to be told it lapsed, not to be + * shown nothing. + * + * Searched by name or share account number, because a member at the counter has a passbook rather than a client id. + */ + @GET + @Path("/member-history") + @Produces(MediaType.APPLICATION_JSON) + public String memberHistory(@QueryParam("search") final String search) { + final String term = search == null ? "" : search.trim(); + if (term.length() < 2) { + return gson.toJson(new JsonArray()); + } + final String like = "%" + term.toLowerCase() + "%"; + + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id AS client_id, c.display_name AS client_name, sha.account_no AS share_account_no, + p.name AS product_name, d.dividend_period_start_date, d.dividend_period_end_date, + sad.amount AS entitled_amount, + l.id AS lock_id, l.status AS lock_status, l.amount AS lock_amount, + l.locked_date, l.release_due_date, ln.account_no AS applied_loan_account_no, + cr.status AS claim_status, cr.dd_number, cr.actioned_at + FROM m_share_account_dividend_details sad + JOIN m_share_account sha ON sha.id = sad.account_id + JOIN m_client c ON c.id = sha.client_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + JOIN m_share_product p ON p.id = d.product_id + LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id + LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id + LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') + WHERE sad.amount > 0 + AND (LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(sha.account_no, '')) LIKE ?) + ORDER BY c.display_name, d.dividend_period_end_date DESC + """, like, like); + + final JsonArray result = new JsonArray(); + for (final Map row : rows) { + final JsonObject entry = new JsonObject(); + entry.addProperty("clientId", ((Number) row.get("client_id")).longValue()); + entry.addProperty("clientName", (String) row.get("client_name")); + entry.addProperty("shareAccountNo", (String) row.get("share_account_no")); + entry.addProperty("productName", (String) row.get("product_name")); + final Object periodEnd = row.get("dividend_period_end_date"); + entry.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date"))); + entry.addProperty("periodEndDate", String.valueOf(periodEnd)); + entry.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd))); + entry.addProperty("entitledAmount", (BigDecimal) row.get("entitled_amount")); + final Object lockId = row.get("lock_id"); + entry.addProperty("lockId", lockId == null ? null : ((Number) lockId).longValue()); + // No lock row at all means the entitlement was below the payable minimum and was swept + // with the rounding residue — never anybody's money to collect. + entry.addProperty("status", lockId == null ? "BELOW_MINIMUM" : (String) row.get("lock_status")); + entry.addProperty("amount", (BigDecimal) row.get("lock_amount")); + entry.addProperty("lockedDate", row.get("locked_date") == null ? null : String.valueOf(row.get("locked_date"))); + entry.addProperty("releaseDueDate", row.get("release_due_date") == null ? null : String.valueOf(row.get("release_due_date"))); + entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no")); + entry.addProperty("claimStatus", (String) row.get("claim_status")); + entry.addProperty("ddNumber", (String) row.get("dd_number")); + entry.addProperty("claimedOn", row.get("actioned_at") == null ? null : String.valueOf(row.get("actioned_at"))); + result.add(entry); + } + return gson.toJson(result); + } + /** * Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a * member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3 @@ -193,14 +322,26 @@ public class DividendDashboardApiResource { final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, " + "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, " + "l.applied_loan_id, ln.account_no AS applied_loan_account_no, " - + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " // + + "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name, " // + // The share account the dividend was earned on. A clerk searching for a member at the + // counter has the passbook in front of them, which carries the account number and not + // the display name as the system spells it — so the number has to be searchable too. + + "sha.account_no AS share_account_no, " // + // The draft this dividend is being paid out as, and how far along it is. Once a claim + // exists the clerk needs the DD number rather than another Claim button — it is what + // goes on the counterfoil and what the member asks about when they come back. + + "cr.status AS claim_status, cr.dd_number AS claim_dd_number " // + "FROM m_share_dividend_lock l " // + "JOIN m_client c ON c.id = l.client_id " // + "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " // + "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " // + "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " // + + "LEFT JOIN m_share_account sha ON sha.id = sad.account_id " // + "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " // + "LEFT JOIN m_share_product p ON p.id = d.product_id " // + // The live claim only: a rejected one leaves the dividend claimable again, so it must + // not keep showing a DD number the member will never receive. + + "LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') " // + (filterByStatus ? "WHERE l.status = ? " : "") // // Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that // needs attention, and it is the one a member should be paid out of first. @@ -214,6 +355,9 @@ public class DividendDashboardApiResource { entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue()); entry.addProperty("clientId", ((Number) row.get("client_id")).longValue()); entry.addProperty("clientName", (String) row.get("client_name")); + entry.addProperty("shareAccountNo", (String) row.get("share_account_no")); + entry.addProperty("claimStatus", (String) row.get("claim_status")); + entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number")); final Object savingsAccountId = row.get("savings_account_id"); entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue()); entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no")); @@ -247,9 +391,14 @@ public class DividendDashboardApiResource { public String list() { final List> locks = jdbcTemplate.queryForList(""" SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount, - l.locked_date, l.release_due_date + l.locked_date, l.release_due_date, + -- The share account the dividend was earned on, so a clerk holding the + -- member's passbook can find the row by its number rather than by name. + sha.account_no AS share_account_no FROM m_share_dividend_lock l JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + LEFT JOIN m_share_account sha ON sha.id = sad.account_id WHERE l.status = 'UNCLAIMED' AND EXISTS ( SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id @@ -266,6 +415,7 @@ public class DividendDashboardApiResource { row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue()); row.addProperty("clientId", clientId); row.addProperty("clientName", (String) lock.get("client_name")); + row.addProperty("shareAccountNo", (String) lock.get("share_account_no")); final Object savingsAccountId = lock.get("savings_account_id"); row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue()); row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 066de64b9..b180a5365 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -217,6 +217,8 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler; @@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional; public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService { private final PlatformSecurityContext context; + private final VoucherService voucherService; private final LoanTransactionValidator loanTransactionValidator; private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer; private final LoanRepositoryWrapper loanRepositoryWrapper; @@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); + // Money leaving the society to the borrower: the Loan Portfolio account is debited, because + // the loan is now an asset owed back, and the fund the money came out of is credited. + // Skipped for an account transfer, where the disbursement lands in another account rather + // than being paid out and that account's own posting carries the voucher. + if (!isAccountTransfer && disbursalTransactionId != null) { + final String voucherNo = raiseLoanVoucher(loan, disbursalTransactionId, loan.getDisbursedAmount(), true); + if (voucherNo != null) { + changes.put("voucherNo", voucherNo); + } + } + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(loan.getId()) // @@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf .build(); } + /** + * Records a loan disbursement or repayment in the voucher register, and returns its number. + * + * The borrower's side is the product's Loan Portfolio account, because that is what the balance + * posts against; the line still names the borrower and their loan account number, so the slip + * reads as theirs. The other side is the till. + * + * A disbursement debits the portfolio — the society now holds a receivable — and credits the + * cash paid out. A repayment reverses that. + * + * A failure here does not fail the transaction. The disbursement or repayment is already posted + * and the borrower's balance already moved; taking that back over a slip would undo a correct + * transaction for a paperwork reason. The problem is logged and the transaction stands. + */ + private String raiseLoanVoucher(final Loan loan, final Long transactionId, final BigDecimal amount, final boolean isDisbursement) { + // A register further out may have already raised a voucher naming the instrument this + // movement belongs to; its voucher describes it better than a bare loan posting would. + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final AppUser currentUser = this.context.authenticatedUser(); + final Map portfolio = this.voucherService.loanPortfolioAccount(loan.getId()); + final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue(); + + final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null; + final String accountNo = loan.getAccountNumber(); + final String partyName = party != null ? party : (String) portfolio.get("name"); + + final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount) + : VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount); + + // The till. The loan module does not resolve a GL for the cashier's drawer, so the line + // names it without an id rather than guessing at one. + final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount) + : VoucherLine.debitGl(null, "Cash in Hand", null, amount); + + final List lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide); + + return this.voucherService.createVoucherWithLines( + isDisbursement ? VoucherService.TYPE_DISBURSEMENT : VoucherService.TYPE_REPAYMENT, + VoucherService.REF_LOAN_TRANSACTION, transactionId, currentUser, + isDisbursement ? VoucherService.PREFIX_DEBIT : VoucherService.PREFIX_CREDIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for loan transaction {}", transactionId, e); + return null; + } + } + private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) { if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText); @@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf loan = loanTransaction.getLoan(); this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction); + // Money coming back from the borrower: cash is debited and the Loan Portfolio account is + // credited, reducing what the loan still carries as owed. + final String repaymentVoucherNo = raiseLoanVoucher(loan, loanTransaction.getId(), transactionAmount, false); + if (repaymentVoucherNo != null) { + changes.put("voucherNo", repaymentVoucherNo); + } + return new CommandProcessingResultBuilder().withCommandId(command.commandId()) // .withLoanId(loan.getId()) // .withEntityId(loanTransaction.getId()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index d2c1ff73e..425a31928 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -176,6 +176,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.rate.service.RateAssembler; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; @@ -413,7 +414,7 @@ public class LoanAccountConfiguration { @Bean @ConditionalOnMissingBean(LoanWritePlatformService.class) - public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, + public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService, LoanTransactionValidator loanTransactionValidator, LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper, LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository, @@ -446,7 +447,8 @@ public class LoanAccountConfiguration { LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper, LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService, LoanTransactionService loanTransactionService) { - return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer, + return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator, + loanUpdateCommandFromApiJsonDeserializer, loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository, loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService, holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java index 561924545..e394eae20 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/nachclearing/service/NachWritePlatformServiceImpl.java @@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService { } if (txn.isCredit()) { this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId, - amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId()); + amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType()); } else { this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId, - amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId()); + amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java new file mode 100644 index 000000000..2fca89e25 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Runs the day: accrue for every account, then pay out any product whose cycle ended today. + * + * Accrual and posting are separated on purpose. Accrual touches no money and can be repeated + * freely; posting moves money and must not be. Keeping them apart means a posting that fails can + * be retried without recomputing the accrual, and an accrual can be rebuilt after a backdated + * transaction without disturbing what has already been paid. + * + * Each product posts in its own transaction, so one product failing does not roll back another + * that already settled. The failure is recorded against that product's run and the day continues. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsDailyInterestOrchestrator { + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsPooledInterestPostingService postingService; + + /** Accrue for the day, then post for any product whose cycle ends today. */ + public void runForDate(final LocalDate businessDate) { + accrualService.accrueForDate(businessDate); + + final List> schedules = jdbcTemplate.queryForList(""" + SELECT s.savings_product_id, s.posting_period_enum + FROM m_savings_interest_posting_schedule s + JOIN m_savings_product p ON p.id = s.savings_product_id + WHERE s.is_active = true AND p.deposit_type_enum = 100 + ORDER BY s.savings_product_id + """); + + for (final Map schedule : schedules) { + final long productId = ((Number) schedule.get("savings_product_id")).longValue(); + final Long dueCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_daily_interest + WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true + AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0 + """, Long.class, productId, businessDate); + if (dueCount == null || dueCount == 0) { + updateSchedule(productId, businessDate, null); + continue; + } + + try { + final BigDecimal paid = postingService.postForProduct(productId, businessDate); + updateSchedule(productId, businessDate, "COMPLETED"); + log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); + } catch (final RuntimeException e) { + // The product rolled back on its own; record why and let the others run. + log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE savings_product_id = ? AND posting_date = ? + """, truncate(e.getMessage()), productId, businessDate); + updateSchedule(productId, businessDate, "FAILED"); + } + } + } + + /** + * Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger + * itself recorded, so the schedule and the rows can never drift apart. + */ + private void updateSchedule(final long productId, final LocalDate businessDate, final String status) { + final List> cycle = jdbcTemplate.queryForList(""" + SELECT MIN(cycle_end_date) AS next_end FROM m_savings_daily_interest + WHERE savings_product_id = ? AND balance_date = ? AND cycle_end_date >= ? + """, productId, businessDate, businessDate); + + LocalDate nextPosting = null; + if (!cycle.isEmpty() && cycle.get(0).get("next_end") != null) { + nextPosting = ((java.sql.Date) cycle.get(0).get("next_end")).toLocalDate(); + if (nextPosting.isEqual(businessDate) && "COMPLETED".equals(status)) { + final int period = jdbcTemplate.queryForObject( + "SELECT posting_period_enum FROM m_savings_interest_posting_schedule WHERE savings_product_id = ?", + Integer.class, productId); + nextPosting = SavingsInterestCycleCalculator.nextCycleEndDate(businessDate, period, + financialYearBeginningMonth()); + } + } + + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_schedule + SET next_posting_date = ?, + last_posting_date = CASE WHEN ? = 'COMPLETED' THEN ? ELSE last_posting_date END, + last_run_status = COALESCE(?, last_run_status), + last_run_at = CURRENT_TIMESTAMP, updated_on = CURRENT_TIMESTAMP + WHERE savings_product_id = ? + """, nextPosting, status, businessDate, status, productId); + } + + private int financialYearBeginningMonth() { + final Integer month = jdbcTemplate.queryForObject( + "SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class); + return month == null || month < 1 || month > 12 ? 1 : month; + } + + private static String truncate(final String message) { + if (message == null) { + return "unknown failure"; + } + return message.length() > 1000 ? message.substring(0, 1000) : message; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java new file mode 100644 index 000000000..2c00c0b66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -0,0 +1,445 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Writes the day-by-day working behind each savings account's interest. + * + * The engine already computes interest correctly; what it does not do is leave a record. It keeps + * one derived total per account and recomputes it from the whole transaction history whenever + * anybody looks, so there is no way to ask what an account earned on a particular day, or why a + * day earned nothing. This service produces that record: one row per account per day, carrying the + * balance it was worked out on and the running total for the cycle. + * + * The interest terms - rate, posting period, compounding, calculation type, days in year - are read + * from the PRODUCT, deliberately. Fineract copies them onto each account at creation and the engine + * afterwards reads the account's copy, so the two can drift: in this tenant seventeen accounts on a + * quarterly 10% product still carry a daily 7% from whenever they were opened. Interest here is a + * property of the product a member bought, not of a stale copy, so this ledger states the product's + * terms and the whole product posts on one rhythm. The consequence is that these figures can differ + * from total_interest_earned_derived on any account whose copy has drifted; that difference is the + * point, and the reconciliation reports it rather than hiding it. + * + * The minimum balance is the exception: it is held only on the account, never on the product. + * + * Two rules the engine enforces and this has to match, or the ledger and the account will disagree: + * + * - Interest is taken on the day's CLOSING balance. EndOfDayBalance holds an opening balance too + * but never uses it in the arithmetic; opening is kept here only because it chains. + * - A day whose closing balance is under the account's minimum earns nothing at all, not a + * reduced amount. That is a cliff, and the row records it so a zero does not read as a fault. + * + * Scope is savings only - deposit_type_enum 100. Fixed and recurring deposits share + * m_savings_account and the same interest engine, but they carry maturity and lock-in rules this + * ledger does not model, so they are left alone. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsDailyInterestService { + + private static final MathContext MC = new MathContext(19, RoundingMode.HALF_EVEN); + private static final int SAVINGS_DEPOSIT_TYPE = 100; + private static final int ACTIVE_STATUS = 300; + + /** m_savings_account.interest_calculation_type_enum: 1 daily balance, 2 average daily balance. */ + private static final class SavingsInterestCalculationType { + + private SavingsInterestCalculationType() {} + + static final int AVERAGE_DAILY_BALANCE = 2; + } + + private final JdbcTemplate jdbcTemplate; + + /** + * Writes one day's rows for every active savings account, for both savings products. + * + * Safe to run more than once for the same day: rows are keyed on (account, date) and a repeat + * updates in place rather than accruing twice. That matters because a day rewritten after a + * backdated transaction has to be able to replace what is already there. + * + * @return how many rows were written + */ + @Transactional + public int accrueForDate(final LocalDate businessDate) { + final int financialYearBeginningMonth = financialYearBeginningMonth(); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sp.deposit_type_enum = ? + AND sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + AND sa.activatedon_date <= ? + ORDER BY sa.product_id, sa.id + """, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS, businessDate); + + int written = 0; + for (final Map account : accounts) { + written += accrueOneAccount(account, businessDate, financialYearBeginningMonth) ? 1 : 0; + } + log.info("Savings daily interest: wrote {} rows for {}", written, businessDate); + return written; + } + + private boolean accrueOneAccount(final Map account, final LocalDate businessDate, + final int financialYearBeginningMonth) { + final Long accountId = ((Number) account.get("id")).longValue(); + final Long productId = ((Number) account.get("product_id")).longValue(); + final LocalDate activatedOn = ((java.sql.Date) account.get("activatedon_date")).toLocalDate(); + final BigDecimal rate = (BigDecimal) account.get("rate"); + final int postingPeriod = ((Number) account.get("posting_period")).intValue(); + final Number compounding = (Number) account.get("compounding_period"); + final Number calcType = (Number) account.get("calculation_type"); + final int daysInYear = daysInYear(account.get("days_in_year"), businessDate); + final BigDecimal minBalance = (BigDecimal) account.get("min_balance"); + + // The previous row is the only thing that carries the chain forward. Its absence means + // this is the account's first day; a gap would mean a day the job never ran, and rather + // than silently re-anchoring we take the last row we do have and let the reconciliation + // report the break. + final List> previous = jdbcTemplate.queryForList(""" + SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, + cycle_start_date, cycle_end_date, is_posting_day + FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date < ? + ORDER BY balance_date DESC LIMIT 1 + """, accountId, businessDate); + + final BigDecimal openingBalance; + final BigDecimal carriedInterest; + final int cycleNumber; + final LocalDate cycleStart; + final LocalDate cycleEnd; + + if (previous.isEmpty()) { + openingBalance = BigDecimal.ZERO; + carriedInterest = BigDecimal.ZERO; + cycleNumber = 1; + // Cycle 1 is a stub: it starts when the account was activated, not on the grid. + cycleStart = activatedOn; + cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth); + } else { + final Map prev = previous.get(0); + openingBalance = (BigDecimal) prev.get("closing_balance"); + final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); + final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); + if (prevWasPosting) { + // The cycle closed yesterday: the sum went to the member and starts again at zero. + carriedInterest = BigDecimal.ZERO; + cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; + cycleStart = prevCycleEnd.plusDays(1); + cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod, + financialYearBeginningMonth); + } else { + carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); + cycleNumber = ((Number) prev.get("cycle_number")).intValue(); + cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); + cycleEnd = prevCycleEnd; + } + } + + final BigDecimal movement = dayMovement(accountId, businessDate); + final BigDecimal closingBalance = openingBalance.add(movement); + + // The minimum is a cliff, not a taper: below it nothing is earned. It is tested against + // whichever balance the account actually earns on, which is not always today's closing. + final boolean averageDailyBalance = calcType != null + && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE; + + final BigDecimal dailyInterest; + final BigDecimal cumulative; + final boolean belowMinimumForInterest; + + if (averageDailyBalance) { + // Average daily balance is a statement about the whole period, not about one day: + // the engine averages every closing balance in the cycle and applies the rate once + // across all of the days at once. So what this works out is the cycle's running + // total, and the day's own interest is whatever that total moved by since yesterday. + // Early in a cycle the figure is provisional - a later deposit shifts the average and + // restates it - which is inherent to the method, not an artefact of writing it down. + final int daysSoFar = (int) (businessDate.toEpochDay() - cycleStart.toEpochDay()) + 1; + final BigDecimal averageBalance = averageBalanceForCycle(accountId, cycleStart, businessDate, + closingBalance); + belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && averageBalance.compareTo(minBalance) < 0; + if (belowMinimumForInterest || averageBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null + || rate.compareTo(BigDecimal.ZERO) <= 0) { + cumulative = carriedInterest; + dailyInterest = BigDecimal.ZERO; + } else { + cumulative = averageBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC) + .multiply(BigDecimal.valueOf(daysSoFar), MC); + dailyInterest = cumulative.subtract(carriedInterest); + } + } else { + belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && closingBalance.compareTo(minBalance) < 0; + if (belowMinimumForInterest || closingBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null + || rate.compareTo(BigDecimal.ZERO) <= 0) { + dailyInterest = BigDecimal.ZERO; + } else { + dailyInterest = closingBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC); + } + cumulative = carriedInterest.add(dailyInterest); + } + final boolean isPostingDay = !businessDate.isBefore(cycleEnd); + + jdbcTemplate.update(""" + INSERT INTO m_savings_daily_interest + (savings_product_id, savings_account_id, balance_date, opening_balance, closing_balance, + daily_interest, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, + posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum, + posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET + opening_balance = EXCLUDED.opening_balance, + -- A day whose interest has already been paid keeps the balance it was left + -- with. Re-accruing that day recomputes the closing balance from + -- transactions alone, which does not include this ledger's own posting, and + -- writing it back would erase the credit from the chain: the next day would + -- open lower than the member's real balance and they would stop earning on + -- interest they have been given. + closing_balance = CASE + WHEN m_savings_daily_interest.posted_amount IS NOT NULL + THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount + ELSE EXCLUDED.closing_balance END, + daily_interest = EXCLUDED.daily_interest, + -- Likewise the total that was actually paid: once money has moved on the + -- strength of this figure, recomputing it would leave the ledger disagreeing + -- with the member's passbook. A posted day is settled. + cycle_cumulative_interest = CASE + WHEN m_savings_daily_interest.posted_amount IS NOT NULL + THEN m_savings_daily_interest.cycle_cumulative_interest + ELSE EXCLUDED.cycle_cumulative_interest END, + cycle_number = EXCLUDED.cycle_number, + cycle_start_date = EXCLUDED.cycle_start_date, + cycle_end_date = EXCLUDED.cycle_end_date, + posting_date = EXCLUDED.posting_date, + is_posting_day = EXCLUDED.is_posting_day, + below_min_balance = EXCLUDED.below_min_balance, + -- The terms are re-stamped too. A row rewritten under different product terms + -- must not keep the old rate beside a figure the new one produced, or the row + -- stops explaining itself. Days already posted keep their money above; this + -- only restates what the money was worked out from. + interest_rate = EXCLUDED.interest_rate, + days_in_year = EXCLUDED.days_in_year, + compounding_period_enum = EXCLUDED.compounding_period_enum, + posting_period_enum = EXCLUDED.posting_period_enum, + calculation_type_enum = EXCLUDED.calculation_type_enum, + min_balance_for_interest = EXCLUDED.min_balance_for_interest, + last_recomputed_on = CURRENT_TIMESTAMP + """, productId, accountId, businessDate, openingBalance, closingBalance, dailyInterest, cumulative, + cycleNumber, cycleStart, cycleEnd, cycleEnd, isPostingDay, rate, daysInYear, + compounding == null ? null : compounding.intValue(), postingPeriod, + calcType == null ? null : calcType.intValue(), minBalance, belowMinimumForInterest); + return true; + } + + /** + * The mean of the cycle's daily closing balances, today included. + * + * Rows already written for this cycle carry the earlier days; today's closing is not among + * them yet, so it is added by the caller. A day the ledger never wrote would pull the mean + * off, which is the other reason the chain check matters. + */ + private BigDecimal averageBalanceForCycle(final Long accountId, final LocalDate cycleStart, + final LocalDate businessDate, final BigDecimal todaysClosing) { + final BigDecimal earlierSum = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(closing_balance), 0) FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ? + """, BigDecimal.class, accountId, cycleStart, businessDate); + final Long earlierDays = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_daily_interest + WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ? + """, Long.class, accountId, cycleStart, businessDate); + final long days = (earlierDays == null ? 0L : earlierDays) + 1L; + final BigDecimal total = (earlierSum == null ? BigDecimal.ZERO : earlierSum).add(todaysClosing); + return total.divide(BigDecimal.valueOf(days), MC); + } + + /** + * The day's net movement, excluding interest postings. + * + * Interest postings are left out on purpose. The posting is this ledger's own output, and + * folding it back in here would let it accrue interest on itself a second time. It is added to + * the closing balance by the posting run, on the posting day, once. + */ + private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { + final BigDecimal movement = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE + WHEN t.transaction_type_enum IN (1, 8) THEN t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + ELSE 0 END), 0) + FROM m_savings_account_transaction t + WHERE t.savings_account_id = ? + AND t.transaction_date = ? + AND t.is_reversed = false + """, BigDecimal.class, accountId, businessDate); + return movement == null ? BigDecimal.ZERO : movement; + } + + /** + * 365, 360, or the actual length of the year. The column holds the enum value, which for the + * two fixed options is the number itself; ACTUAL is 1 and means the calendar year. + */ + private int daysInYear(final Object raw, final LocalDate businessDate) { + if (raw == null) { + return 365; + } + final int value = ((Number) raw).intValue(); + if (value == 360 || value == 365) { + return value; + } + return businessDate.lengthOfYear(); + } + + private int financialYearBeginningMonth() { + final Integer month = jdbcTemplate.queryForObject( + "SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class); + return month == null || month < 1 || month > 12 ? 1 : month; + } + + /** + * Rebuilds the ledger for one account from a date forward, for when a backdated transaction + * changes what earlier days should have earned. + * + * Recomputing in place rather than writing correction rows: the ledger's job is to say what an + * account earned, and after a backdated deposit the honest answer is the new figure. The + * last_recomputed_on stamp is what records that the row was revisited. + */ + @Transactional + public int recomputeAccountFrom(final long accountId, final LocalDate fromDate, final LocalDate toDate) { + final int financialYearBeginningMonth = financialYearBeginningMonth(); + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, accountId); + if (rows.isEmpty()) { + return 0; + } + final Map account = rows.get(0); + int rewritten = 0; + for (LocalDate day = fromDate; !day.isAfter(toDate); day = day.plusDays(1)) { + accrueOneAccount(account, day, financialYearBeginningMonth); + rewritten++; + } + log.info("Savings daily interest: recomputed {} days for account {} from {}", rewritten, accountId, fromDate); + return rewritten; + } + + /** + * Backfills every active savings account from activation up to the given date. + * + * Existing accounts have months of history the ledger knows nothing about; without this the + * table starts blank and the first cycle it sees is a partial one that will not reconcile + * against the account's own totals. + */ + @Transactional + public int backfillAll(final LocalDate upToDate) { + final List accountIds = jdbcTemplate.queryForList(""" + SELECT sa.id FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sp.deposit_type_enum = ? AND sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + ORDER BY sa.id + """, Long.class, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS); + + int total = 0; + for (final Long accountId : accountIds) { + final java.sql.Date activated = jdbcTemplate.queryForObject( + "SELECT activatedon_date FROM m_savings_account WHERE id = ?", java.sql.Date.class, accountId); + if (activated == null) { + continue; + } + total += recomputeAccountFrom(accountId, activated.toLocalDate(), upToDate); + } + log.info("Savings daily interest: backfilled {} rows across {} accounts", total, accountIds.size()); + return total; + } + + /** + * The two checks worth running: that no day's opening lost touch with the day before it, and + * that the open cycle still agrees with what the account itself reports as unposted. + */ + public List reconcile() { + final List problems = new ArrayList<>(); + + jdbcTemplate.queryForList(""" + SELECT curr.savings_account_id, curr.balance_date, curr.opening_balance, prev.closing_balance + FROM m_savings_daily_interest curr + JOIN m_savings_daily_interest prev + ON prev.savings_account_id = curr.savings_account_id + AND prev.balance_date = curr.balance_date - INTERVAL '1 day' + WHERE curr.opening_balance <> prev.closing_balance + ORDER BY curr.savings_account_id, curr.balance_date + """).forEach(row -> problems.add(String.format( + "chain break: account %s on %s opens at %s but previous day closed at %s", + row.get("savings_account_id"), row.get("balance_date"), row.get("opening_balance"), + row.get("closing_balance")))); + + jdbcTemplate.queryForList(""" + SELECT d.savings_account_id, d.cycle_cumulative_interest AS ledger, + COALESCE(sa.total_interest_earned_derived, 0) - COALESCE(sa.total_interest_posted_derived, 0) AS account + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + WHERE d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_account_id = d.savings_account_id) + AND ABS(d.cycle_cumulative_interest + - (COALESCE(sa.total_interest_earned_derived, 0) + - COALESCE(sa.total_interest_posted_derived, 0))) > 0.01 + """).forEach(row -> problems.add(String.format( + "unposted mismatch: account %s ledger says %s, account says %s", row.get("savings_account_id"), + row.get("ledger"), row.get("account")))); + + return problems; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java new file mode 100644 index 000000000..61a19c305 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestCycleCalculator.java @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; + +/** + * Where a posting cycle ends, for a given posting period. + * + * This mirrors SavingsHelper.determineInterestPostingPeriodEndDateFrom, which is private to the + * engine and works on the engine's own period objects. The daily ledger needs the same boundaries + * to label its rows, so the rule is restated here rather than reached into. + * + * The boundaries are calendar dates anchored on the financial year start, not anniversaries of the + * account: an account opened on 24 August under a quarterly product does not post on 24 November, + * it posts at the next quarter end on the grid. The consequence is that the first cycle of any + * account is a stub - activation to the first boundary - and every cycle after it is a full period. + * + * One deliberate difference from the engine. Fineract adds a day to the period end and posts on the + * morning after ("interest posting always occurs on next day after the period end date"), unless + * the savings-interest-posting-current-period-end configuration is on, in which case it posts on + * the period end itself. This ledger always uses the period end date, matching that configuration + * being enabled, which is the behaviour this feature was asked for. If the configuration is left + * off, the engine's own posting transaction will be dated one day later than the ledger's + * posting_date, and the reconciliation will show it - the amounts still agree, only the date moves. + */ +public final class SavingsInterestCycleCalculator { + + private SavingsInterestCycleCalculator() {} + + // m_savings_product.interest_posting_period_enum / SavingsPostingInterestPeriodType + public static final int DAILY = 1; + public static final int MONTHLY = 4; + public static final int QUARTERLY = 5; + public static final int BIANNUAL = 6; + public static final int ANNUAL = 7; + + /** + * The last day of the cycle that {@code within} falls in - the day interest is posted. + * + * @param within + * any day inside the cycle + * @param postingPeriodEnum + * the account's posting period + * @param financialYearBeginningMonth + * 1-12, from the financial-year-beginning-month configuration + */ + public static LocalDate cycleEndDate(final LocalDate within, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + switch (postingPeriodEnum) { + case DAILY: + return within; + case MONTHLY: + return within.with(TemporalAdjusters.lastDayOfMonth()); + case QUARTERLY: + return periodEndOnGrid(within, financialYearBeginningMonth, 3); + case BIANNUAL: + return periodEndOnGrid(within, financialYearBeginningMonth, 6); + case ANNUAL: + return periodEndOnGrid(within, financialYearBeginningMonth, 12); + default: + throw new IllegalArgumentException("Unsupported savings interest posting period: " + postingPeriodEnum); + } + } + + /** + * The first day of the cycle that {@code within} falls in, ignoring activation. Callers hold + * the account's start date and take the later of the two, which is what makes cycle 1 a stub. + */ + public static LocalDate cycleStartDate(final LocalDate within, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + if (postingPeriodEnum == DAILY) { + return within; + } + final LocalDate previousEnd = cycleEndDate(within, postingPeriodEnum, financialYearBeginningMonth); + // The cycle containing `within` starts the day after the previous cycle ended. + return cycleEndDate(previousEnd.minusMonths(monthsIn(postingPeriodEnum)), postingPeriodEnum, + financialYearBeginningMonth).plusDays(1); + } + + /** The cycle after the one ending on {@code cycleEnd}. */ + public static LocalDate nextCycleEndDate(final LocalDate cycleEnd, final int postingPeriodEnum, + final int financialYearBeginningMonth) { + return cycleEndDate(cycleEnd.plusDays(1), postingPeriodEnum, financialYearBeginningMonth); + } + + private static int monthsIn(final int postingPeriodEnum) { + switch (postingPeriodEnum) { + case MONTHLY: + return 1; + case QUARTERLY: + return 3; + case BIANNUAL: + return 6; + case ANNUAL: + return 12; + default: + return 0; + } + } + + /** + * Walks the fixed grid of period ends starting at the financial year month and returns the + * first one on or after {@code within}. + * + * The grid is built from the financial year start rather than from January, so a year + * beginning in April gives quarter ends of 30 Jun, 30 Sep, 31 Dec, 31 Mar - the same dates + * the engine produces for the same configuration. + */ + private static LocalDate periodEndOnGrid(final LocalDate within, final int financialYearBeginningMonth, + final int monthsPerPeriod) { + // Last day of the month before the financial year starts, in the year `within` falls in. + LocalDate gridAnchor = within.withDayOfMonth(1).withMonth(financialYearBeginningMonth).minusDays(1); + if (gridAnchor.isAfter(within)) { + gridAnchor = gridAnchor.minusYears(1); + } + LocalDate candidate = gridAnchor; + // At most 12/monthsPerPeriod steps land on or past `within`; the extra step guards the + // case where the anchor itself is the answer. + for (int i = 0; i <= 12 / monthsPerPeriod; i++) { + if (!candidate.isBefore(within)) { + return candidate; + } + candidate = candidate.plusDays(1).plusMonths(monthsPerPeriod).minusDays(1) + .with(TemporalAdjusters.lastDayOfMonth()); + } + return candidate; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java new file mode 100644 index 000000000..4c2ca6b81 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -0,0 +1,300 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pays a savings product's accrued interest to its members, pooled through two control accounts. + * + * Stock Fineract posts interest one member at a time - debit Interest on Savings, credit Savings + * Control, per account - and its posting job spreads that work across a thread pool, each batch in + * its own transaction. That is sound double entry, but it has no seam a failure can be caught on: + * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere + * holds the figure that would say so. + * + * This posts the product's total first, into Savings Interest Posting Control, moves it to Savings + * Interest Posted Control, and only then pays it out to members. The whole product settles inside + * one transaction, and both control accounts have to read zero before it is allowed to commit. A + * failure rolls the product back entirely - nobody is half paid - and a run that never reached + * commit leaves a PENDING row naming the product and date for the next run to pick up. + * + * The paise problem is the one worth stating plainly. Each member's interest is rounded to two + * decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool + * ends a few paise adrift and that money is simply gone. Here the residual is worked out and given + * to the largest holding - the account least distorted by it - so the pool closes exactly and the + * amount that moved is written down in the run record. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsPooledInterestPostingService { + + /** Two decimals: what the member is actually credited. */ + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + + private static final String GL_POSTING_CONTROL = "200304"; + private static final String GL_POSTED_CONTROL = "200305"; + + /** m_savings_account_transaction.transaction_type_enum */ + private static final int INTEREST_POSTING = 3; + /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ + private static final int ENTITY_TYPE_SAVINGS = 2; + private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3; + + private final JdbcTemplate jdbcTemplate; + + /** + * Posts one product's interest for one posting date. + * + * REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other + * product's completed run down with it. + * + * @return the amount distributed, or zero if there was nothing to do + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { + final Long completed = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_interest_posting_run + WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED' + """, Long.class, productId, postingDate); + if (completed != null && completed > 0) { + log.info("Savings interest posting: product {} already completed for {}", productId, postingDate); + return BigDecimal.ZERO; + } + + // The accounts due today, with what they accrued. Anything already carrying a posting + // transaction is skipped, so a resumed run cannot pay somebody a second time. + // A savings account has no office of its own: it belongs to a client, or to a group when + // there is no client, and the office is theirs. The journal entries have to be raised + // against that office or they land in the wrong branch's books. + final List> due = jdbcTemplate.queryForList(""" + SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, + d.cycle_cumulative_interest AS gross_interest, + COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code, + sa.currency_digits + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND COALESCE(c.office_id, g.office_id) IS NOT NULL + ORDER BY d.savings_account_id + """, productId, postingDate); + + if (due.isEmpty()) { + log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate); + return BigDecimal.ZERO; + } + + final long runId = openRun(productId, postingDate, due.size()); + + // Round each member's share, then make the rounded shares add up. The residual is the + // difference between what the pool holds and what the members would otherwise receive. + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal gross = (BigDecimal) row.get("gross_interest"); + final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); + row.put("rounded_interest", rounded); + grossTotal = grossTotal.add(gross); + roundedTotal = roundedTotal.add(rounded); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + + if (residual.compareTo(BigDecimal.ZERO) != 0) { + // To the largest balance: the account where a paisa is proportionally smallest. + final Map largest = due.stream() + .max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow(); + largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); + log.info("Savings interest posting: residual {} assigned to account {}", residual, + largest.get("savings_account_id")); + } + + final Long postingControlGl = glAccountId(GL_POSTING_CONTROL); + final Long postedControlGl = glAccountId(GL_POSTED_CONTROL); + final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); + final String currency = (String) due.get(0).get("currency_code"); + final String poolReference = "SIP-" + productId + "-" + postingDate; + + // Stage 1: the product's whole interest bill, recognised and pooled. + journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); + + // Stage 2: the pool moves to the account it is paid out of. + journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + + // Stage 3: out to the members, one at a time. + BigDecimal distributed = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + final long accountId = ((Number) row.get("savings_account_id")).longValue(); + final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount); + // The member's own branch, which need not be the branch the pool was raised in. + final long accountOfficeId = ((Number) row.get("office_id")).longValue(); + + final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, + newBalance); + + journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, + accountId); + creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, + accountId, transactionId); + + jdbcTemplate.update(""" + UPDATE m_savings_daily_interest + SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, + closing_balance = closing_balance + ? + WHERE id = ? + """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); + + jdbcTemplate.update(""" + UPDATE m_savings_account + SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ? + WHERE id = ? + """, amount, amount, accountId); + + distributed = distributed.add(amount); + } + + // The whole point of the two control accounts: if this does not hold, nothing commits. + if (pooled.compareTo(distributed) != 0) { + throw new IllegalStateException(String.format( + "Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s", + productId, postingDate, pooled, distributed)); + } + + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?, + status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP + WHERE id = ? + """, pooled, distributed, residual, runId); + + log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId, + postingDate, distributed, due.size(), residual); + return distributed; + } + + private long openRun(final long productId, final LocalDate postingDate, final int accountCount) { + jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_run + (savings_product_id, posting_date, account_count, status) + VALUES (?, ?, ?, 'PENDING') + ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET + account_count = EXCLUDED.account_count, status = 'PENDING', + started_at = CURRENT_TIMESTAMP, failure_reason = NULL + """, productId, postingDate, accountCount); + return jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_interest_posting_run + WHERE savings_product_id = ? AND posting_date = ? + """, Long.class, productId, postingDate); + } + + private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date, + final BigDecimal amount, final BigDecimal runningBalance) { + jdbcTemplate.update(""" + INSERT INTO m_savings_account_transaction + (savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction, + transaction_type_enum, transaction_date, amount, running_balance_derived, + submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc) + VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, accountId, officeId, INTEREST_POSTING, date, amount, runningBalance, date, systemUserId(), + systemUserId()); + return jdbcTemplate.queryForObject(""" + SELECT id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ? + ORDER BY id DESC LIMIT 1 + """, Long.class, accountId, date, INTEREST_POSTING); + } + + /** The member's own leg: the product's Savings Control account, credited per account. */ + private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, + final LocalDate date, final BigDecimal amount, final String reference, final long accountId, + final long transactionId) { + final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL + journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); + } + + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) { + journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); + } + + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, + final Long transactionId) { + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + savings_transaction_id, reversed, manual_entry, office_running_balance, + organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date, + created_on_utc, last_modified_on_utc, submitted_on_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount, + debit ? "Savings interest posting" : "Savings interest posted", + savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, + systemUserId(), systemUserId(), date); + } + + /** + * The user these rows are attributed to. The job runs without anybody logged in, and the audit + * columns are NOT NULL, so entries are stamped to the system account the scheduler uses. + */ + private Long systemUserId() { + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class); + return ids.isEmpty() ? 1L : ids.get(0); + } + + private Long glAccountId(final String glCode) { + return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode); + } + + private Long mappedGlAccount(final long productId, final int financialAccountType) { + return jdbcTemplate.queryForObject(""" + SELECT gl_account_id FROM acc_product_mapping + WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? + LIMIT 1 + """, Long.class, productId, financialAccountType); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java new file mode 100644 index 000000000..82b77585a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -0,0 +1,178 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.api; + +import com.google.gson.Gson; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Reads the savings daily interest ledger, per product and per account. + * + * The per-product listing is the view this was built for: pick a product, see its accounts and + * where each one is in its cycle. The per-account listing is the working itself - a day per row, + * the balance it was taken on, and the running total resetting to zero the day interest is paid. + */ +@Path("/v1/savingsdailyinterest") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsDailyInterestApiResource { + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsDailyInterestOrchestrator orchestrator; + private final Gson gson = new Gson(); + + /** The savings products carrying a ledger, with where each one is in its timetable. */ + @GET + @Path("/products") + public String products() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT p.id, p.name, p.short_name, p.nominal_annual_interest_rate AS rate, + s.posting_period_enum, s.run_at_time, s.last_posting_date, s.next_posting_date, + s.last_run_status, s.is_active, + (SELECT COUNT(*) FROM m_savings_account sa + WHERE sa.product_id = p.id AND sa.status_enum = 300) AS active_accounts, + (SELECT COALESCE(SUM(d.cycle_cumulative_interest), 0) + FROM m_savings_daily_interest d + WHERE d.savings_product_id = p.id + AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_product_id = p.id)) AS accrued_this_cycle + FROM m_savings_product p + LEFT JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id + WHERE p.deposit_type_enum = 100 + ORDER BY p.id + """); + return gson.toJson(rows); + } + + /** One product's accounts as at the latest day the ledger holds. */ + @GET + @Path("/products/{productId}/accounts") + public String accountsForProduct(@PathParam("productId") final Long productId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.savings_account_id, sa.account_no, c.display_name AS client_name, + d.balance_date, d.opening_balance, d.closing_balance, d.daily_interest, + d.cycle_cumulative_interest, d.cycle_number, d.cycle_start_date, d.cycle_end_date, + d.below_min_balance + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + WHERE d.savings_product_id = ? + AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_product_id = ?) + ORDER BY sa.account_no + """, productId, productId); + return gson.toJson(rows); + } + + /** One account's day-by-day working, newest first. */ + @GET + @Path("/accounts/{accountId}") + public String accountLedger(@PathParam("accountId") final Long accountId, + @QueryParam("limit") final Integer limit) { + final int rowLimit = limit == null || limit <= 0 ? 200 : Math.min(limit, 2000); + final List> rows = jdbcTemplate.queryForList(""" + SELECT balance_date, opening_balance, closing_balance, daily_interest, + cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, + is_posting_day, posted_amount, savings_transaction_id, below_min_balance, + interest_rate, days_in_year, min_balance_for_interest, last_recomputed_on + FROM m_savings_daily_interest + WHERE savings_account_id = ? + ORDER BY balance_date DESC + LIMIT ? + """, accountId, rowLimit); + return gson.toJson(rows); + } + + /** What each posting run pooled and paid, and whether it finished. */ + @GET + @Path("/runs") + public String runs(@QueryParam("productId") final Long productId) { + final String sql = """ + SELECT r.id, r.savings_product_id, p.name AS product_name, r.posting_date, r.account_count, + r.pooled_amount, r.distributed_amount, r.rounding_residual, r.status, + r.failure_reason, r.started_at, r.completed_at + FROM m_savings_interest_posting_run r + JOIN m_savings_product p ON p.id = r.savings_product_id + """ + (productId == null ? "" : " WHERE r.savings_product_id = ? ") + + " ORDER BY r.posting_date DESC, r.savings_product_id"; + final List> rows = productId == null ? jdbcTemplate.queryForList(sql) + : jdbcTemplate.queryForList(sql, productId); + return gson.toJson(rows); + } + + /** + * The chain check and the unposted-total check. An empty list is the answer you want: every + * day picks up where the last one left off, and the ledger still agrees with the accounts. + */ + @GET + @Path("/reconcile") + public String reconcile() { + final List problems = accrualService.reconcile(); + final Map result = new LinkedHashMap<>(); + result.put("clean", problems.isEmpty()); + result.put("problemCount", problems.size()); + result.put("problems", problems); + return gson.toJson(result); + } + + /** Runs a day by hand, for a first backfill or after fixing a failed night. */ + @POST + @Path("/run") + public String run(@QueryParam("date") final String dateParam) { + final LocalDate businessDate = dateParam == null || dateParam.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(dateParam); + orchestrator.runForDate(businessDate); + final Map result = new LinkedHashMap<>(); + result.put("ranFor", businessDate.toString()); + return gson.toJson(result); + } + + /** Rebuilds the ledger for every account from activation to the given date. */ + @POST + @Path("/backfill") + public String backfill(@QueryParam("upTo") final String upToParam) { + final LocalDate upTo = upToParam == null || upToParam.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(upToParam); + final int rows = accrualService.backfillAll(upTo); + final Map result = new LinkedHashMap<>(); + result.put("rowsWritten", rows); + result.put("upTo", upTo.toString()); + return gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 5ea7fe588..212c9732c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -97,6 +97,8 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; @@ -139,6 +141,8 @@ import org.springframework.util.CollectionUtils; public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService { private final PlatformSecurityContext context; + private final VoucherService voucherService; + private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; private final SavingsAccountDataValidator fromApiJsonDeserializer; private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper; private final StaffRepositoryWrapper staffRepository; @@ -344,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } + // Money arriving at the counter: cash is debited and the member's savings control account + // is credited. + final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true); + if (depositVoucherNo != null) { + changes.put("voucherNo", depositVoucherNo); + } + return new CommandProcessingResultBuilder() // .withEntityId(deposit.getId()) // .withOfficeId(account.officeId()) // @@ -420,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } + // Money leaving the member over the counter: their savings control account is debited and + // the till that pays it out is credited. + final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false); + if (withdrawalVoucherNo != null) { + changes.put("voucherNo", withdrawalVoucherNo); + } + return new CommandProcessingResultBuilder() // .withEntityId(withdrawal.getId()) // .withOfficeId(account.officeId()) // @@ -584,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.savingAccountRepositoryWrapper.saveAndFlush(account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); + + // Interest credited to the member, recorded against the transactions this run created. + // existingTransactionIds is what the account held before posting, so anything not in it + // is new — which is how a re-run over an account that already had interest posted last + // quarter raises a voucher for this quarter's credit only. + raiseInterestVouchers(account, existingTransactionIds, backdatedTxnsAllowedTill); } } @@ -2008,4 +2032,79 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory"); } } -} + + private void raiseInterestVouchers(final SavingsAccount account, final Set existingTransactionIds, + final boolean backdatedTxnsAllowedTill) { + if (!this.voucherService.shouldRaiseVoucher()) { + return; + } + + try { + final List transactions = backdatedTxnsAllowedTill + ? account.getSavingsAccountTransactionsWithPivotConfig() + : account.getTransactions(); + + final Map control = this.voucherService.savingsControlAccount(account.getId()); + final Map expense = this.voucherService.savingsInterestExpenseAccount(account.getId()); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue(); + + final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; + final String partyName = party != null ? party : (String) control.get("name"); + final String accountNo = account.getAccountNumber(); + final AppUser currentUser = this.context.authenticatedUser(); + + for (final SavingsAccountTransaction transaction : transactions) { + if (transaction.getId() == null || existingTransactionIds.contains(transaction.getId()) + || !transaction.isInterestPostingAndNotReversed()) { + continue; + } + final BigDecimal amount = transaction.getAmount(); + if (amount == null || amount.signum() <= 0) { + continue; + } + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, + transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT, + List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount) + .withDescription("Interest posted to " + accountNo), + VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) + .withDescription("Interest credited"))); + } + } catch (final RuntimeException e) { + log.error("Could not raise interest vouchers for savings account {}", account.getId(), e); + } + } + + private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount, + final boolean isDeposit) { + if (!this.voucherService.shouldRaiseVoucher()) { + return null; + } + + try { + final AppUser currentUser = this.context.authenticatedUser(); + final Map control = this.voucherService.savingsControlAccount(account.getId()); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + + final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; + final String accountNo = account.getAccountNumber(); + + final VoucherLine memberSide = isDeposit + ? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount) + : VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount); + + final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount) + : VoucherLine.creditGl(null, "Cash in Hand", null, amount); + + final List lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide); + + return this.voucherService.createVoucherWithLines( + isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId, + currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for savings transaction {}", transactionId, e); + return null; + } + } +} \ No newline at end of file diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 722d25070..d732e933a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -69,6 +69,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator; import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator; @@ -463,9 +464,10 @@ public class SavingsConfiguration { EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, - ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) { - return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, - staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, + ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) { + return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer, + savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, + savingsAccountTransactionDataValidator, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService, savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index 30d621458..b735ef91a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -41,7 +41,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400; + /** Retained profit, where a dividend nobody claimed returns to. */ + private static final String PROFIT_GL_CODE = "BANKPROFIT01"; // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; @@ -117,13 +118,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe * *
      *   Dr  Dividend Payable            — the obligation ends; it was never going to be paid
-     *       Cr  Dividend Forfeited Income
+     *       Cr  Bank Profit
      * 
* - * Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and - * loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every - * other income account, which is the same equity account the dividend was declared out of — so the money completes - * its round trip, by way of the P&L. + * Credited straight to Bank Profit rather than routed through an income account: the money never left the society, + * so this is a liability that turned out not to be owed, and it returns directly to the retained profit the + * dividend was declared out of. * * Currency comes straight off the lock row (set from the share account when the lock was created), so this never * depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the @@ -134,15 +134,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) .stream().findFirst().orElse(null); - // Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank - // Profit along with every other income account — so the money still ends up back in the - // equity account the dividend was declared out of, but by way of the P&L, where a forfeited - // dividend is visible as that year's income rather than appearing in equity with no trace. - final Long forfeitedIncomeAccountId = this.jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME) - .stream().findFirst().orElse(null); - if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) { + final Long bankProfitAccountId = this.jdbcTemplate + .queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst() + .orElse(null); + if (payableDividendsAccountId == null || bankProfitAccountId == null) { return; } @@ -164,7 +159,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe // Debit first, then credit, so the entry ids follow the direction the money travelled. this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); - this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT, + this.jdbcTemplate.update(insertSql, bankProfitAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 6f84b86ec..7d6b5e5f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -59,9 +59,13 @@ import org.springframework.stereotype.Component; * (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one * rate, one period, applied to every product that has members holding shares in it. * - * The declarations are created through the existing per-product command, so the whole downstream chain — statutory - * appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it - * does for a single-product declaration. + * The declarations are created through the existing per-product command, so the whole downstream chain — rounding, the + * residue sweep, the funding check against available profit — behaves exactly as it does for a single-product + * declaration. + * + * The rate is applied to share capital and to nothing else. Profit does not size the dividend — it only funds it: the + * amount that falls out of rate x capital is drawn from the Bank Profit (Non-Distributable) GL, and the only thing + * profit decides is whether there is enough there to pay it. */ @Path("/v1/shareproducts/dividends") @Component @@ -75,13 +79,15 @@ public class AllProductsDividendApiResource { /** The account a dividend is declared against, once year-end closing has landed the profit in it. */ private static final String PROFIT_GL_CODE = "BANKPROFITND01"; - private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage"; - private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage"; - /** Every member receives the declared rate on their own capital — what an AGM resolution promises. */ + /** + * A dividend is a rate on each member's own paid-up capital, and nothing else. + * + * The amount is derived entirely from what members invested and for how long — profit does not size it. Profit is + * only the source the payout is funded from, checked separately so the society cannot commit money it does not + * hold. Kept as a constant because the stored declarations still carry a basis field. + */ private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL"; - /** The pool is a share of distributable profit, split across members afterwards. */ - private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT"; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -137,8 +143,7 @@ public class AllProductsDividendApiResource { @GET @Path("/preview") public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, - @QueryParam("ratePercentage") final BigDecimal ratePercentage, - @QueryParam("dividendBasis") final String dividendBasis) { + @QueryParam("ratePercentage") final BigDecimal ratePercentage) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) { @@ -149,26 +154,18 @@ public class AllProductsDividendApiResource { if (to.isBefore(from)) { throw apiError(400, "toDate must not be before fromDate"); } - final String basis = normaliseBasis(dividendBasis); - final List capitals = capitalPerProduct(from, to); final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); + // Not what sizes the dividend — only what funds it. Carried so the screen can warn before the + // officer commits, rather than letting the command layer reject a declaration after the fact. final ProfitWaterfall waterfall = profitWaterfall(from, to); - // Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in - // proportion to the capital each carries — a share of profit is a society-wide figure, so it - // cannot be computed per product the way a rate on capital can. - final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null - ? percentageOf(waterfall.distributableProfit, ratePercentage) - : null; - final List> products = new ArrayList<>(); BigDecimal grandTotal = BigDecimal.ZERO; for (final ProductCapital capital : capitals) { - final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital, - profitPool); + final BigDecimal amount = amountForProduct(ratePercentage, capital.weightedCapital); final Map entry = new LinkedHashMap<>(); entry.put("productId", capital.productId); @@ -187,13 +184,16 @@ public class AllProductsDividendApiResource { out.put("fromDate", fromDate); out.put("toDate", toDate); out.put("financialYear", financialYearOf(toDate)); - out.put("dividendBasis", basis); + out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); out.put("ratePercentage", ratePercentage); out.put("totalWeightedCapital", totalWeightedCapital); out.put("profitGlCode", PROFIT_GL_CODE); - // The whole waterfall, so the screen can show where the declarable figure came from rather - // than presenting it as a number the officer has to take on trust. + // The funding position, not the basis. Carried so a rate the profit account cannot cover is + // visible on screen before submission — the command layer enforces the same ceiling, and an + // officer should not discover it only when the declaration is rejected. out.putAll(waterfall.asMap()); + out.put("fundingAvailable", waterfall.distributableProfit); + out.put("fundingShortfall", grandTotal.subtract(waterfall.distributableProfit).max(BigDecimal.ZERO)); // What is left in the profit account after this declaration — the amount that will be swept // to Bank Profit on approval, and which no later year's dividend can draw on. out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO)); @@ -225,7 +225,6 @@ public class AllProductsDividendApiResource { } final LocalDate from = parseDate(fromDate); final LocalDate to = parseDate(toDate); - final String basis = normaliseBasis(getString(json, "dividendBasis")); final String rateText = getString(json, "ratePercentage"); if (rateText == null) { @@ -237,10 +236,6 @@ public class AllProductsDividendApiResource { } final List capitals = capitalPerProduct(from, to); - final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add); - final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) - ? percentageOf(profitWaterfall(from, to).distributableProfit, rate) - : null; final List> created = new ArrayList<>(); final List> skipped = new ArrayList<>(); @@ -260,7 +255,7 @@ public class AllProductsDividendApiResource { continue; } - final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool); + final BigDecimal amount = amountForProduct(rate, capital.weightedCapital); if (amount.signum() <= 0) { skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product.")); continue; @@ -281,6 +276,15 @@ public class AllProductsDividendApiResource { .withJson(payload.toString()).build(); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); + // The rate the board actually resolved, recorded as declared. Written here rather + // than carried through the command because the per-product command takes an amount, + // not a rate — and re-deriving the rate later (amount / weighted capital) drifts the + // moment any share movement lands in a closed period. The Dividend Distribution + // Report and the AGM minutes both quote this figure, so it has to be the one + // resolved, not one recomputed. + jdbcTemplate.update("UPDATE m_share_product_dividend_pay_out SET rate_percentage = ? WHERE id = ?", rate, + result.getSubResourceId()); + final Map row = new LinkedHashMap<>(); row.put("productId", productId); row.put("productName", productName); @@ -341,27 +345,16 @@ public class AllProductsDividendApiResource { } /** - * What one product's declaration comes to, under whichever basis the officer chose. + * What one product's declaration comes to: the declared rate on the capital its members hold. * - * RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital, - * so each product's amount follows directly from the capital its members hold. - * - * PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure - * at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the - * same across products — any other split would pay two members with identical holdings different amounts purely - * because they hold different share classes. + * This is what a rate means in an AGM resolution — every member receives that rate on their own paid-up capital, + * pro-rated for how long they held it. The society's profit plays no part in the arithmetic; it only has to be + * large enough to fund the result, which is checked separately at the command layer. */ - private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital, - final BigDecimal totalWeightedCapital, final BigDecimal profitPool) { + private BigDecimal amountForProduct(final BigDecimal ratePercentage, final BigDecimal weightedCapital) { if (ratePercentage == null || weightedCapital.signum() <= 0) { return BigDecimal.ZERO; } - if (BASIS_PERCENT_OF_PROFIT.equals(basis)) { - if (profitPool == null || totalWeightedCapital.signum() <= 0) { - return BigDecimal.ZERO; - } - return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP); - } return percentageOf(weightedCapital, ratePercentage); } @@ -369,78 +362,47 @@ public class AllProductsDividendApiResource { return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } - private String normaliseBasis(final String basis) { - return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL; - } - /** - * The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of - * it, and what is left for a dividend. + * The profit available to fund a dividend for a period. * - * Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the - * society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a - * large one that has already been heavily appropriated — and the reserve and education percentages are - * configurable, so the amounts they take cannot be inferred from the result either. + * Just the one figure, because that is all profit decides now: the dividend amount comes from members' share + * capital, and profit only has to be able to pay it. The statutory reserve and education fund are not deducted + * here — they are appropriated on the profit-distribution screen before a dividend is declared, so the balance + * this reads has already had them taken out. */ private static final class ProfitWaterfall { private final BigDecimal netProfit; - private final BigDecimal statutoryReservePercentage; - private final BigDecimal statutoryReserveAmount; - private final BigDecimal educationFundPercentage; - private final BigDecimal educationFundAmount; private final BigDecimal distributableProfit; - private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage, - final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage, - final BigDecimal educationFundAmount, final BigDecimal distributableProfit) { + private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal distributableProfit) { this.netProfit = netProfit; - this.statutoryReservePercentage = statutoryReservePercentage; - this.statutoryReserveAmount = statutoryReserveAmount; - this.educationFundPercentage = educationFundPercentage; - this.educationFundAmount = educationFundAmount; this.distributableProfit = distributableProfit; } private Map asMap() { final Map out = new LinkedHashMap<>(); out.put("netProfit", netProfit); - out.put("statutoryReservePercentage", statutoryReservePercentage); - out.put("statutoryReserveAmount", statutoryReserveAmount); - out.put("educationFundPercentage", educationFundPercentage); - out.put("educationFundAmount", educationFundAmount); out.put("distributableProfit", distributableProfit); return out; } } /** - * The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education - * fund, which are appropriated before any dividend. + * What the profit account holds for the period, which is what a dividend can be funded from. * * Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never * disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than - * guessed at from a P&L that is still moving. + * guessed at from a P&L that is still moving. A loss-making year funds nothing, so it collapses to zero rather + * than a negative figure that would read as money owed back to the society. */ private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) { final BigDecimal netProfit = netProfitForPeriod(from, to); - - final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE); - final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND); - - // Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and - // nothing to distribute, so the whole waterfall collapses to zero rather than producing a - // negative "reserve" that would read as money owed back to the society. if (netProfit == null || netProfit.signum() <= 0) { final BigDecimal zero = BigDecimal.ZERO.setScale(2); - return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero, - zero); + return new ProfitWaterfall(netProfit == null ? zero : netProfit, zero); } - - final BigDecimal reserve = percentageOf(netProfit, reservePercentage); - final BigDecimal educationFund = percentageOf(netProfit, educationPercentage); - return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund, - netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO)); + return new ProfitWaterfall(netProfit, netProfit); } /** @@ -489,30 +451,27 @@ public class AllProductsDividendApiResource { return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense")); } - /** A configured appropriation rate, or zero when it has not been set. */ - private BigDecimal configuredPercentage(final String configurationName) { - final List values = jdbcTemplate.queryForList( - "SELECT value FROM c_configuration WHERE name = ? AND enabled = true", BigDecimal.class, configurationName); - return values.isEmpty() || values.get(0) == null ? BigDecimal.ZERO : values.get(0); - } - /** * Paid-up capital x days held, across a product's eligible accounts. * * Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a * holding already in force at the period start is treated as starting there, and a purchase that has not cleared * the product's minimum active period is skipped. + * + * The {@code + 1} matches the assembler's: the period end is itself a day the shares were held, so a holding is + * measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the + * obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing. */ private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, final int minimumActivePeriod) { final BigDecimal total = jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(t.amount * (? - GREATEST(t.transaction_date, ?))), 0) + SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) FROM m_share_account sa JOIN m_share_account_transactions t ON t.account_id = sa.id WHERE sa.product_id = ? AND sa.status_enum = 300 AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true AND t.transaction_date <= ? - AND (? - GREATEST(t.transaction_date, ?)) >= ? + AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? """, BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod); return total == null ? BigDecimal.ZERO : total; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java new file mode 100644 index 000000000..5da49befc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareCapitalReturnApiResource.java @@ -0,0 +1,260 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareproducts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Return on Members & Share Capital — the membership and share-capital position a co-operative bank reports to the + * RBI, per share type, with the previous year alongside for comparison. + * + * A supervisor reading this is asking three things of every share type the society offers: how many members hold it, + * how much capital they have put in, and how much of the authorised issue is still unsold. Movement matters as much as + * position — a society whose membership fell while its capital rose has done something quite different from one where + * both grew — so each figure is reported as opening, movement during the year, and closing, against the same three for + * the year before. + * + * Every figure is derived from share accounts and their transactions rather than from any stored summary, so the return + * cannot drift from the register it is meant to describe. + */ +@Path("/v1/shareproducts/capital-return") +@Component +@Tag(name = "Share Capital Return", description = "Members and share capital per share type, current year against previous, for RBI reporting.") +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareCapitalReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "SHAREPRODUCT"; + /** Fineract share account/transaction status: 300 = ACTIVE / APPROVED. */ + private static final int STATUS_ACTIVE = 300; + /** Share transaction type: 500 = purchase, 600 = redemption. */ + private static final int TYPE_PURCHASE = 500; + private static final int TYPE_REDEEM = 600; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * The return for one financial year, per share type, with the prior year beside it. + * + * The year is given as its start year — 2025 means FY 2025-26 — because a financial year is named by the year it + * opens in, and asking a caller to send two dates invites a period that is not a financial year at all. + */ + @GET + public Response report(@QueryParam("financialYear") final Integer financialYearStart) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final int startYear = financialYearStart != null ? financialYearStart : currentFinancialYearStart(); + final LocalDate from = LocalDate.of(startYear, 4, 1); + final LocalDate to = LocalDate.of(startYear + 1, 3, 31); + final LocalDate priorFrom = from.minusYears(1); + final LocalDate priorTo = to.minusYears(1); + + final JsonArray products = new JsonArray(); + for (final Map product : jdbcTemplate.queryForList(""" + SELECT id, name, short_name, currency_code, total_shares, issued_shares, unit_price, capital_amount + FROM m_share_product ORDER BY id + """)) { + final Long productId = ((Number) product.get("id")).longValue(); + + final JsonObject entry = new JsonObject(); + entry.addProperty("productId", productId); + entry.addProperty("shareType", (String) product.get("name")); + entry.addProperty("shortName", (String) product.get("short_name")); + entry.addProperty("currency", (String) product.get("currency_code")); + entry.addProperty("unitPrice", (BigDecimal) product.get("unit_price")); + + // What the society is permitted to issue, against what it actually has. The gap is the + // headroom a supervisor checks a fresh issue against. + final long authorisedShares = asLong(product.get("total_shares")); + entry.addProperty("authorisedShares", authorisedShares); + entry.addProperty("authorisedCapital", (BigDecimal) product.get("capital_amount")); + + entry.add("currentYear", positionFor(productId, from, to)); + entry.add("previousYear", positionFor(productId, priorFrom, priorTo)); + + // Allotted out of authorised, as at the year end being reported — not today, or the + // return would answer a different question than the one it was asked. + final long allottedShares = sharesHeldAsAt(productId, to); + entry.addProperty("allottedShares", allottedShares); + entry.addProperty("unissuedShares", Math.max(0, authorisedShares - allottedShares)); + entry.addProperty("utilisationPercentage", authorisedShares == 0 ? null + : BigDecimal.valueOf(allottedShares).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(authorisedShares), 2, java.math.RoundingMode.HALF_UP)); + products.add(entry); + } + + final JsonObject out = new JsonObject(); + out.addProperty("financialYear", startYear + "-" + String.format("%02d", (startYear + 1) % 100)); + out.addProperty("previousFinancialYear", + (startYear - 1) + "-" + String.format("%02d", startYear % 100)); + out.addProperty("periodStartDate", from.toString()); + out.addProperty("periodEndDate", to.toString()); + out.add("shareTypes", products); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * One share type's position over one financial year: members and capital at the start, what moved during it, and + * where both ended. + * + * Members are counted by distinct client rather than by account, because one member holding three share accounts is + * still one member — counting accounts would overstate the membership a supervisor is being told. + */ + private JsonObject positionFor(final Long productId, final LocalDate from, final LocalDate to) { + final Map row = jdbcTemplate.queryForMap(""" + SELECT + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date < ?) AS members_opening, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date BETWEEN ? AND ?) AS members_admitted, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.closed_date BETWEEN ? AND ?) AS members_ceased, + COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date <= ? + AND (sa.closed_date IS NULL OR sa.closed_date > ?)) AS members_closing + FROM m_share_account sa + WHERE sa.product_id = ? AND sa.status_enum = ? + """, java.sql.Date.valueOf(from), java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), + java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), + productId, STATUS_ACTIVE); + + final Map capital = jdbcTemplate.queryForMap(""" + SELECT + COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.amount ELSE -t.amount END) + FILTER (WHERE t.transaction_date < ?), 0) AS capital_opening, + COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS capital_issued, + COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS capital_redeemed, + COALESCE(SUM(t.total_shares) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0) + AS shares_issued + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? AND sa.status_enum = ? + AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + """, TYPE_PURCHASE, java.sql.Date.valueOf(from), TYPE_PURCHASE, java.sql.Date.valueOf(from), + java.sql.Date.valueOf(to), TYPE_REDEEM, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), TYPE_PURCHASE, + java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE, + TYPE_REDEEM); + + final BigDecimal opening = (BigDecimal) capital.get("capital_opening"); + final BigDecimal issued = (BigDecimal) capital.get("capital_issued"); + final BigDecimal redeemed = (BigDecimal) capital.get("capital_redeemed"); + + final JsonObject out = new JsonObject(); + out.addProperty("membersOpening", asLong(row.get("members_opening"))); + out.addProperty("membersAdmitted", asLong(row.get("members_admitted"))); + out.addProperty("membersCeased", asLong(row.get("members_ceased"))); + out.addProperty("membersClosing", asLong(row.get("members_closing"))); + out.addProperty("capitalOpening", opening); + out.addProperty("capitalIssued", issued); + out.addProperty("capitalRedeemed", redeemed); + // Closing is opening plus what moved, not a separate query: the two must agree, and deriving + // it is what guarantees they do. + out.addProperty("capitalClosing", opening.add(issued).subtract(redeemed)); + out.addProperty("sharesIssued", asLong(capital.get("shares_issued"))); + return out; + } + + /** Shares still held in this product as at a date — purchases less redemptions. */ + private long sharesHeldAsAt(final Long productId, final LocalDate asAt) { + final Long held = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.total_shares ELSE -t.total_shares END), 0) + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? AND sa.status_enum = ? + AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + AND t.transaction_date <= ? + """, Long.class, TYPE_PURCHASE, productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE, TYPE_REDEEM, + java.sql.Date.valueOf(asAt)); + return held == null ? 0L : held; + } + + /** April opens the Indian financial year, so anything before it still belongs to the prior one. */ + private int currentFinancialYearStart() { + final LocalDate today = LocalDate.now(java.time.ZoneId.systemDefault()); + return today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1; + } + + private long asLong(final Object value) { + return value == null ? 0L : ((Number) value).longValue(); + } + + /** The financial years that actually have share activity, newest first, for the year picker. */ + @GET + @Path("/financial-years") + public Response financialYears() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final List years = jdbcTemplate.queryForList(""" + SELECT DISTINCT CASE WHEN EXTRACT(MONTH FROM t.transaction_date) >= 4 + THEN EXTRACT(YEAR FROM t.transaction_date) + ELSE EXTRACT(YEAR FROM t.transaction_date) - 1 END AS fy_start + FROM m_share_account_transactions t + WHERE t.is_active = true AND t.transaction_date IS NOT NULL + ORDER BY fy_start DESC + """, Integer.class); + + final JsonArray out = new JsonArray(); + // The current year is always offered even with no activity yet — a return is filed for the + // year that is running, not only for years that already have movement in them. + final int current = currentFinancialYearStart(); + if (!years.contains(current)) { + out.add(yearEntry(current)); + } + for (final Integer year : years) { + if (year != null) { + out.add(yearEntry(year)); + } + } + return Response.ok(gson.toJson(out)).build(); + } + + private JsonObject yearEntry(final int startYear) { + final JsonObject entry = new JsonObject(); + entry.addProperty("startYear", startYear); + entry.addProperty("label", startYear + "-" + String.format("%02d", (startYear + 1) % 100)); + return entry; + } + + @SuppressWarnings("unused") + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("defaultUserMessage", message); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java index aa99cbaf3..bab29c66e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/ShareDividendCapitalPreviewApiResource.java @@ -200,7 +200,12 @@ public class ShareDividendCapitalPreviewApiResource { } final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500; - if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) { + // The + 1 matches ShareProductDividendAssembler: the period end is itself a day the + // shares were held, so the holding runs to the day after it. Without it a full financial + // year measures 364 days, a product asking for the obvious 365 qualifies nobody, and this + // preview disagrees with the declaration it is previewing. + final long daysHeld = java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) + 1; + if (isPurchase && daysHeld < minimumActivePeriod) { continue; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java index b742bbe0a..b08c8c70a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java @@ -154,7 +154,12 @@ public class ShareProductDividendAssembler { if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) { shareStartDate = lastDividendPostDate; } - int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate); + // The period end is itself a day the member held the shares, so the holding is + // measured to the day AFTER it. Without the +1 a dividend period is one day + // short of the year it represents — 01-Apr-2025 to 31-Mar-2026 subtracts to 364 — + // and a product whose minimum active period is the obvious 365 could never pay a + // dividend for any single financial year, however long the member had held. + int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate) + 1; if (type.isPurchased() && numberOfPurchseDays < minimumActivePeriod) { continue; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index 62624f7d1..ef128c757 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -91,6 +91,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP // FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole // accounting module into this constructor just for two int constants. private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + + /** + * How many financial years of dividend a member may still come and claim. + * + * A society keeps an unclaimed dividend available for three years. Declaring a fourth year is the point the oldest + * one falls out of that window: the member had three full years of the society asking them to collect it, and the + * money is recognised as income rather than sitting as a liability nobody will ever call on. + */ + private static final int CLAIMABLE_FINANCIAL_YEARS = 3; private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; /** @@ -105,11 +114,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP */ private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01"; - // Statutory appropriation funds, seeded by changelog 0478. Resolved by GL code rather than a - // financial activity because they are society-specific funds with no native Fineract activity. - private static final String STATUTORY_RESERVE_GL_CODE = "STATRES01"; - private static final String EDUCATION_FUND_GL_CODE = "EDUFUND01"; - // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; @@ -122,9 +126,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; - private static final String CONFIG_STATUTORY_RESERVE_PERCENTAGE = "dividend-statutory-reserve-percentage"; - private static final String CONFIG_EDUCATION_FUND_PERCENTAGE = "dividend-education-fund-percentage"; - @Override public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) { try { @@ -203,8 +204,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails); final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId); - postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate, - dividendPeriodEndDate, financialYear); + // The statutory reserve and education fund are NOT appropriated here. They are a division + // of the year's profit that happens on its own screen before any dividend is declared, so + // posting them again on declaration would move the same money twice. postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount, financialYear); recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount, @@ -282,8 +284,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP if (netProfit.signum() <= 0) { return; } - final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE)) - .subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE)); + // The balance in the profit GL is already net of the statutory reserve and education fund — + // those were appropriated on the profit-distribution screen before this dividend was + // declared, so deducting them again would sweep away money that is no longer there. + final BigDecimal distributable = netProfit; // Every declaration against an overlapping period, this one included — the pool is // society-wide, so a second product's dividend has already consumed part of it. @@ -436,19 +440,22 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } /** - * A co-operative society may only distribute what is left of net profit after the statutory appropriations the - * Co-operative Societies Act requires first — a Statutory Reserve Fund transfer and a state-federation - * education-fund contribution — and after every dividend already declared against an overlapping period for ANY - * share product, since the profit pool is society-wide rather than per-product. Without this, two products could - * each be declared 60% of profit and the society would have committed 120% of money it never had. + * A dividend must not commit more money than the profit account holds. * - * The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is - * enforced here because that screen is not the only way a declaration can be created, and a stale screen must not - * be able to commit an over-declaration. + * The amount itself is settled before this runs — it is the declared rate on members' paid-up capital, and profit + * plays no part in working it out. This is purely a funding check: whatever the rate produced, the society has to + * actually have it. * - * Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has - * an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other - * declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor. + * What it is checked against is the profit remaining in the Bank Profit GL, which is already net of the statutory + * reserve and education fund — those are appropriated on the profit-distribution screen before any dividend is + * declared, so deducting them again here would hold back money that has already left the account. + * + * Every dividend already declared against an overlapping period for ANY share product is subtracted too, since the + * profit is society-wide rather than per-product. Without that, two products could each be declared the whole + * balance and the society would have committed twice the money it had. + * + * The ceiling is enforced here rather than on the screen because that screen is not the only way a declaration can + * be created, and a stale screen must not be able to commit an over-declaration. */ private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) { @@ -462,25 +469,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP + " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period."); } - final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE); - final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE); - final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund); - - // Every product competes for the same distributable profit, so this deliberately does NOT - // filter by product_id — unlike validateNoOverlappingDividendPeriod, which is about paying - // the same product's members twice for the same days. + // Every product draws on the same profit, so this deliberately does NOT filter by product_id + // — unlike validateNoOverlappingDividendPeriod, which is about paying the same product's + // members twice for the same days. final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ? """, BigDecimal.class, to, from); - final BigDecimal available = distributableProfit.subtract(alreadyDeclared); + final BigDecimal available = netProfit.subtract(alreadyDeclared); if (dividendAmount.compareTo(available) > 0) { throw new DividentProcessingException("exceeds.distributable.profit", - "This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit - + " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves " - + distributableProfit + ", of which " + alreadyDeclared + " is already declared — only " - + available.max(BigDecimal.ZERO) + " remains available."); + "This dividend of " + dividendAmount + " is more than the profit available to fund it for this period. Bank profit " + + netProfit + ", of which " + alreadyDeclared + " is already declared — only " + available.max(BigDecimal.ZERO) + + " remains available."); } } @@ -540,70 +542,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP return startYear + "-" + String.format("%02d", (startYear + 1) % 100); } - private BigDecimal percentageOfNetProfit(final BigDecimal netProfit, final String configurationName) { - final List> rows = jdbcTemplate - .queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName); - if (rows.isEmpty() || rows.get(0).get("value") == null) { - return BigDecimal.ZERO; - } - final BigDecimal percentage = new BigDecimal(String.valueOf(rows.get(0).get("value"))); - return netProfit.multiply(percentage).divide(BigDecimal.valueOf(100), 6, java.math.RoundingMode.HALF_UP); - } - - /** - * Posts the statutory appropriations that must come out of profit before a dividend may be declared. - * - *
-     *   Dr  Bank Profit               Cr  Statutory Reserve Fund   (25% of net profit, equity — retained permanently)
-     *   Dr  Bank Profit               Cr  Education Fund Payable   (1%, a liability owed to the state federation)
-     * 
- * - * Until now these only ever reduced the ceiling the declaration screen enforced — the money was never actually - * moved, so the Statutory Reserve Fund and Education Fund GLs stayed empty and the balance sheet did not show the - * transfers the Co-operative Societies Act requires. Posting them here makes the appropriation real. - * - * The rates come from c_configuration (changelog 0478), so a society on a different state Act, or one whose rates - * are revised, changes the setting rather than the code. A disabled or missing row means that appropriation does not - * apply and nothing is posted for it. - * - * Posted once per declaration and only for the appropriations that resolve — a society whose GL setup is incomplete - * still gets its dividend declared rather than being blocked. - */ - private void postStatutoryAppropriations(final Long dividendPayOutId, final String currencyCode, final LocalDate periodStartDate, - final LocalDate periodEndDate, final String financialYear) { - - final java.sql.Date from = java.sql.Date.valueOf(periodStartDate); - final java.sql.Date to = java.sql.Date.valueOf(periodEndDate); - final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to); - if (netProfit.signum() <= 0) { - return; - } - - final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); - if (bankProfitAccountId == null) { - return; - } - - postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(STATUTORY_RESERVE_GL_CODE), - percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE), currencyCode, financialYear, - "statutory reserve", "DIVSTATRES"); - postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(EDUCATION_FUND_GL_CODE), - percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE), currencyCode, financialYear, - "education fund", "DIVEDUFUND"); - } - - /** One appropriation out of profit: debit Bank Profit, credit the fund it is set aside into. */ - private void postAppropriation(final Long dividendPayOutId, final Long bankProfitAccountId, final Long fundAccountId, - final BigDecimal amount, final String currencyCode, final String financialYear, final String label, - final String transactionIdPrefix) { - - if (fundAccountId == null || amount.signum() <= 0) { - return; - } - final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); - postJournalEntryPair(bankProfitAccountId, fundAccountId, amount, currencyCode, transactionIdPrefix + dividendPayOutId, entryDate, - "FY " + financialYear + " " + label + " appropriated from profit — declaration #" + dividendPayOutId); - } private Long glAccountIdByCode(final String glCode) { return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst() @@ -742,6 +680,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP releaseReserveToDividendPayable(dividendPayOutDetails); sweepRoundingResidueOnApproval(PayOutDetailId); makeDividendsClaimable(PayOutDetailId); + forfeitDividendsBeyondClaimWindow(dividendPayOutDetails); closeOffUndistributedProfit(dividendPayOutDetails); return new CommandProcessingResultBuilder() // .withEntityId(PayOutDetailId) // @@ -762,6 +701,82 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls, * just two bulk statements) to run synchronously in the approve request itself -- no background job needed. */ + /** + * Forfeits any dividend that has fallen outside the three-year claim window, now that a newer year has been + * approved. + * + *
+     *   Dr  Dividend Payable            — the obligation ends; nobody is coming to claim it
+     *       Cr  Bank Profit
+     * 
+ * + * A society keeps three financial years of dividend claimable. Approving a fourth is what pushes the oldest out: + * counted by financial year rather than by elapsed days, so "three years of dividend" means what a member and an + * auditor both take it to mean, and a declaration made late does not buy the member extra time. + * + * Only UNCLAIMED locks are touched. A claimed one has already been paid out by draft and its money left Dividend + * Payable long ago. + * + * Credited straight to Bank Profit rather than routed through an income account: the money never left the society, + * so this is a liability that turned out not to be owed, and it returns directly to the retained profit it was + * declared out of. + * + * Skipped when the GL accounts are not mapped, exactly like every other entry here: incomplete GL setup must never + * block an approval. + */ + private void forfeitDividendsBeyondClaimWindow(final ShareProductDividendPayOutDetails declaration) { + final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate(); + if (periodEndDate == null) { + return; + } + + // Everything ending more than three financial years before the one just approved. Compared on + // the period end date, which is what identifies a declaration's financial year. + final LocalDate oldestClaimablePeriodEnd = periodEndDate.minusYears(CLAIMABLE_FINANCIAL_YEARS); + + final List> expired = jdbcTemplate.queryForList(""" + SELECT l.id, l.amount, l.currency_code, d.dividend_period_end_date + FROM m_share_dividend_lock l + JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id + JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id + WHERE l.status = 'UNCLAIMED' AND d.dividend_period_end_date <= ? + """, java.sql.Date.valueOf(oldestClaimablePeriodEnd)); + if (expired.isEmpty()) { + return; + } + + final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); + final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); + if (payableDividendsAccountId == null || bankProfitAccountId == null) { + return; + } + + final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); + for (final Map lock : expired) { + final Long lockId = ((Number) lock.get("id")).longValue(); + final BigDecimal amount = (BigDecimal) lock.get("amount"); + if (amount == null || amount.signum() <= 0) { + continue; + } + final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate()); + + postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"), + "DIVFORF" + lockId, entryDate, + "FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS + + " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate)); + + jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId); + } + } + + /** A GL account mapped to a financial activity, or null when the society has not configured it. */ + private Long glAccountIdByFinancialActivity(final int financialActivityType) { + return jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + financialActivityType) + .stream().findFirst().orElse(null); + } + private void makeDividendsClaimable(final Long payOutDetailId) { final LocalDate today = DateUtils.getBusinessLocalDate(); final java.sql.Date lockedDate = java.sql.Date.valueOf(today); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index 792daa49c..e27e53176 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -23,10 +23,12 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -37,6 +39,8 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; +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.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; @@ -47,6 +51,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Service @RequiredArgsConstructor +@Slf4j public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService { private static final String STATUS_PENDING = "PENDING"; @@ -56,6 +61,22 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private static final String METHOD_CHEQUE = "CHEQUE"; private static final String METHOD_CASH = "CASH"; + /** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */ + private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST"; + + /** + * A draft the society raises to pay a member their own dividend. + * + * Unlike every other method there is no purchaser: the society already owes the money, so nothing is collected and + * the draft is funded out of the Dividend Payable liability instead. Raised by the dividend claim flow, approved + * here alongside every other draft. + */ + private static final String METHOD_DIVIDEND = "DIVIDEND"; + + /** Financial activity ids for the dividend liability accounts (see the dividend claim flow). */ + private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; + // Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing, // so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit // aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which @@ -67,6 +88,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; private final FineractRequestContextHolder fineractRequestContextHolder; private final SponsorDDWritePlatformService sponsorDDWritePlatformService; @@ -471,6 +493,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } } else if (METHOD_CHEQUE.equals(paymentMethod)) { drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } else if (METHOD_DIVIDEND.equals(paymentMethod)) { + // Nothing is collected from the member — the society already owes them this money — so + // the draft is funded by settling the Dividend Payable liability instead of debiting a + // purchaser. Routed through Dividend Paid so the society can read what has been claimed + // and what is still unclaimed as two separate balances rather than only the net. + drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber); } else { // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the // request was submitted, so approval clears the suspense rather than debiting cash again. @@ -495,10 +523,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, issueRequestId); + if (METHOD_DIVIDEND.equals(paymentMethod)) { + settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName()); + } + + raiseIssueVoucher(issueRequestId, ddNumber, drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, chargesGlAccountId, + gstAmount, gstGlAccountId, currentUser); + return new CommandProcessingResultBuilder().withEntityId(issueRequestId) .withSubEntityId(issueResult.getResourceId()).build(); } + /** + * Closes off the dividend claim this draft was raised for, now that the draft actually exists. + * + * Approving the draft IS approving the claim — they are the same decision — so the claim is marked APPROVED here + * rather than waiting on a second sign-off in another screen. + * + * A claim for less than the whole lock leaves the remainder UNCLAIMED for a later visit; a claim for all of it + * closes the lock. Both are what the member is actually owed afterwards, so the lock has to move either way. + * + * Silent when no claim is attached: a DIVIDEND draft raised any other way is still a valid draft, and refusing to + * approve it would strand money the society owes. + */ + private void settleDividendClaim(final Long issueRequestId, final String ddNumber, final BigDecimal faceValue, final Long checkerId, + final String checkerName) { + + final Map claim = this.jdbcTemplate + .queryForList("SELECT id, lock_id FROM m_share_dividend_claim_request WHERE dd_issue_request_id = ? AND status = 'PENDING'", + issueRequestId) + .stream().findFirst().orElse(null); + if (claim == null) { + return; + } + final Long claimId = ((Number) claim.get("id")).longValue(); + final Long lockId = ((Number) claim.get("lock_id")).longValue(); + + final BigDecimal lockAmount = this.jdbcTemplate + .queryForList("SELECT amount FROM m_share_dividend_lock WHERE id = ?", BigDecimal.class, lockId).stream().findFirst() + .orElse(BigDecimal.ZERO); + final BigDecimal remainder = lockAmount.subtract(faceValue); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, + lockId); + } else { + this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", + lockId); + } + + this.jdbcTemplate.update(""" + UPDATE m_share_dividend_claim_request + SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), dd_number = ? + WHERE id = ? + """, checkerId, checkerName, ddNumber, claimId); + } + @Override @Transactional public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) { @@ -540,6 +619,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "cash_refund_journal_entry_id = ? WHERE id = ?", STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId); + if (METHOD_DIVIDEND.equals(paymentMethod)) { + // Rejecting the draft rejects the claim it was raised for — nothing was posted and the + // lock was never touched, so the dividend simply stays UNCLAIMED and can be claimed + // again. Leaving the claim PENDING would block that with a request nobody will action. + this.jdbcTemplate.update(""" + UPDATE m_share_dividend_claim_request + SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? + WHERE dd_issue_request_id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), rejectionReason, issueRequestId); + } + return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build(); } @@ -1068,7 +1158,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); return result.getResourceId().toString(); } @@ -1087,7 +1180,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); return result.getResourceId().toString(); } @@ -1121,6 +1217,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } + /** + * Records an approved Demand Draft issue in the voucher register. + * + * The slip mirrors the posting: the funding side is debited for everything the purchaser parts + * with, and the proceeds are split the way the ledger splits them — the draft's face value to + * the DD Payable liability, and the charges and GST to their own income and tax accounts rather + * than being folded into the liability. + * + * The debit is the total, not the face value: a purchaser handing over face value plus charges + * has parted with the total, and a slip showing only the face value would not match what they + * paid. + * + * A failure here does not fail the issue. The draft is already raised, the leaflet already + * consumed and the ledger already posted; withdrawing that over a slip would take back a + * completed instrument for a paperwork reason. The problem is logged and the issue stands. + */ + private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId, + final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId, + final BigDecimal gstAmount, final Long gstGlAccountId, final AppUser currentUser) { + try { + final BigDecimal charges = chargesAmount == null ? BigDecimal.ZERO : chargesAmount; + final BigDecimal gst = gstAmount == null ? BigDecimal.ZERO : gstAmount; + final BigDecimal total = faceValue.add(charges).add(gst); + + final List lines = new ArrayList<>(); + + final Map funding = this.voucherService.glAccount(fundingGlAccountId); + lines.add(VoucherLine + .debitGl(fundingGlAccountId, (String) funding.get("name"), (String) funding.get("gl_code"), total) + .withDescription("Sponsor DD " + ddNumber)); + + final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); + lines.add(VoucherLine + .creditGl(ddPayableGlAccountId, (String) ddPayable.get("name"), (String) ddPayable.get("gl_code"), faceValue) + .withDescription("Draft " + ddNumber + " face value")); + + if (charges.signum() > 0 && chargesGlAccountId != null) { + final Map chargesGl = this.voucherService.glAccount(chargesGlAccountId); + lines.add(VoucherLine + .creditGl(chargesGlAccountId, (String) chargesGl.get("name"), (String) chargesGl.get("gl_code"), charges) + .withDescription("DD issuance charges")); + } + if (gst.signum() > 0 && gstGlAccountId != null) { + final Map gstGl = this.voucherService.glAccount(gstGlAccountId); + lines.add(VoucherLine.creditGl(gstGlAccountId, (String) gstGl.get("name"), (String) gstGl.get("gl_code"), gst) + .withDescription("GST on DD charges")); + } + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId, + currentUser, VoucherService.PREFIX_DEBIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e); + } + } + /** * Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for @@ -1140,6 +1291,46 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI txnDate, comments, linkedAccountId); } + /** + * Settles the member's dividend entitlement and returns the account the draft is then drawn against. + * + *
+     *   Dr  Dividend Payable      Cr  Dividend Paid     (here — the member's entitlement is discharged)
+     *   Dr  Dividend Paid         Cr  Sponsor DD Payable (by the caller — the draft is raised)
+     * 
+ * + * Routed through Dividend Paid rather than posted straight across so the society can read what has been claimed + * and what is still unclaimed as two separate balances instead of only the net of Dividend Payable. Dividend Paid + * nets to nil, leaving its credit total as the cumulative dividend claimed. + * + * A society that has not mapped Dividend Paid still gets a correct payout — Dividend Payable is returned and the + * caller posts straight across to DD Payable — just without that separate measure. + */ + private Long resolveDividendFundingGlAccount(final BigDecimal faceValue, final Long officeId, final LocalDate issuedDate, + final String ddNumber) { + + final Long payableDividendsAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); + if (payableDividendsAccountId == null) { + throw new IllegalStateException("No Payable Dividends GL account is mapped. Ask an administrator to configure the " + + "dividend financial activity accounts before approving a dividend Demand Draft."); + } + final Long dividendPaidAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_DIVIDEND_PAID); + if (dividendPaidAccountId == null) { + return payableDividendsAccountId; + } + + postGlOffsetEntry(payableDividendsAccountId, dividendPaidAccountId, faceValue, null, null, officeId, issuedDate, + "Dividend paid by Demand Draft " + ddNumber + " — member's entitlement settled", null); + return dividendPaidAccountId; + } + + private Long financialActivityGlAccount(final int financialActivityType) { + return this.jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + financialActivityType) + .stream().findFirst().orElse(null); + } + /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, @@ -1192,7 +1383,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withJson(payload.toString()) .build(); this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); + // The DD register raises its own voucher, naming the draft and its number; the inner + // posting must not raise a second one over the same money. + final CommandProcessingResult result = this.voucherService + .withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd)); final String transactionId = result != null ? result.getTransactionId() : null; if (transactionId == null) return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 2c45c36d1..ab901f3b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -33,13 +33,16 @@ import jakarta.ws.rs.core.Response; import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; +import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -68,11 +71,12 @@ public class VoucherVerificationApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final VoucherService voucherService; private final Gson gson = new Gson(); // ── POST /v1/vouchers/{voucherNo}/verify ────────────────────────────────── // The voucher number travels in the body, not the path: numbers are formatted - // CR/001/2108/0001 and Spring Security's strict firewall rejects the encoded slashes. + // CR/2108/0001 and Spring Security's strict firewall rejects the encoded slashes. @POST @Path("verify") @@ -412,6 +416,223 @@ public class VoucherVerificationApiResource { return gson.toJson(Map.of("resourceId", id, "deleted", true)); } + // ── GET /v1/vouchers/report?from=2026-08-01&to=2026-08-31&username=rajesh ── + + /** + * The vouchers raised over a period, one row each, with the accounts they moved money between. + * + * Who sees whose work is decided here rather than by the caller: a clerk, head cashier or + * manager sees every user's vouchers and may filter to one; anyone else sees only their own, + * whatever they ask for. A cashier cannot read a colleague's day by passing their username. + * + * Each row carries the debit and credit sides already joined, so the report reads as a register + * — voucher number, what was debited, what was credited — without a second call per row. A + * voucher with more than two sides (a DD with charges, a compound journal) is summarised by its + * first debit and first credit with a count of the rest, because a register is a list of + * movements and the full breakdown belongs on the voucher itself. + */ + @GET + @Path("report") + public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, + @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) { + + final AppUser currentUser = this.context.authenticatedUser(); + final boolean seesEveryone = isCheckerRole(currentUser); + + // Defaults to today rather than to everything: an unbounded register over a year of + // vouchers is a report nobody asked for and a query that scans the whole table. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate fromDate = parseReportDate(from, today); + final LocalDate toDate = parseReportDate(to, today); + if (toDate.isBefore(fromDate)) { + throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid", + "The end of the period cannot fall before its start."); + } + + // A user who may only see their own work is pinned to their own username regardless of what + // they asked for; a checker's blank filter means everyone. + final String scopedUsername = seesEveryone ? (username == null || username.isBlank() ? null : username.trim()) + : currentUser.getUsername(); + + final StringBuilder sql = new StringBuilder(""" + SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name, + ver.status AS verification_status, + dr.party_name AS debit_party, dr.account_no AS debit_account, + cr.party_name AS credit_party, cr.account_no AS credit_account, + (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count + FROM m_voucher v + LEFT JOIN m_office o ON o.id = v.branch_office_id + LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'DR' + ORDER BY line_no LIMIT 1) dr ON TRUE + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'CR' + ORDER BY line_no LIMIT 1) cr ON TRUE + WHERE v.created_at >= ? AND v.created_at < ? + """); + + final List args = new java.util.ArrayList<>(); + args.add(Timestamp.valueOf(fromDate.atStartOfDay())); + // Exclusive upper bound on the day after, so a voucher raised at 23:59 on the last day is + // still inside the period — a BETWEEN on dates would drop it. + args.add(Timestamp.valueOf(toDate.plusDays(1).atStartOfDay())); + + if (scopedUsername != null) { + sql.append(" AND v.created_by_username = ?"); + args.add(scopedUsername); + } + if (voucherType != null && !voucherType.isBlank()) { + sql.append(" AND v.voucher_type = ?"); + args.add(voucherType.trim()); + } + sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC"); + + final List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + BigDecimal total = BigDecimal.ZERO; + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + if (amount != null) { + total = total.add(amount); + } + + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", r.get("voucher_no")); + m.put("voucherType", r.get("voucher_type")); + m.put("amount", amount); + m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); + m.put("userId", r.get("created_by")); + m.put("username", r.get("created_by_username")); + m.put("branchName", r.get("branch_name")); + m.put("verificationStatus", r.get("verification_status")); + m.put("debitParty", r.get("debit_party")); + m.put("debitAccount", r.get("debit_account")); + m.put("creditParty", r.get("credit_party")); + m.put("creditAccount", r.get("credit_account")); + // Anything beyond one debit and one credit is extra detail the register does not show, + // so the row says how much it is hiding rather than misrepresenting the voucher. + final int lineCount = ((Number) r.get("line_count")).intValue(); + m.put("extraLines", Math.max(0, lineCount - 2)); + out.add(m); + } + + final Map res = new LinkedHashMap<>(); + res.put("from", fromDate.toString()); + res.put("to", toDate.toString()); + res.put("username", scopedUsername); + // Tells the screen whether to offer the user filter at all, rather than having it guess + // from the caller's roles a second time. + res.put("canSeeAllUsers", seesEveryone); + res.put("vouchers", out); + res.put("count", out.size()); + res.put("totalAmount", total); + return gson.toJson(res); + } + + /** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */ + private static LocalDate parseReportDate(final String value, final LocalDate fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + try { + return LocalDate.parse(value.trim()); + } catch (final java.time.format.DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.voucher.report.date.invalid", + "Dates must be written as yyyy-MM-dd; got '" + value + "'."); + } + } + + // ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ──────────────────────── + // A query parameter for the same reason as verify: a formatted voucher number contains + // slashes, which the strict firewall rejects even encoded, so it cannot be a path segment. + + /** + * One voucher and the debit and credit sides it is made of — what a printed voucher shows. + * + * The sides come back in print order, with each line's running side total, so the caller can + * lay out a slip without re-deriving anything. A voucher raised before lines were recorded + * returns an empty list rather than an error: the voucher is real, its posting simply was not + * captured at the time, and reporting it as missing would be wrong. + */ + @GET + @Path("detail") + public String detail(@QueryParam("voucherNo") final String voucherNo) { + this.context.authenticatedUser(); + + if (voucherNo == null || voucherNo.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required."); + } + + final List> header = jdbcTemplate.queryForList(""" + SELECT v.voucher_no, v.voucher_type, v.reference_type, v.reference_id, v.amount, + v.branch_office_id, o.name AS branch_name, + v.created_by_username, v.created_at, + ver.status AS verification_status + FROM m_voucher v + LEFT JOIN m_office o ON o.id = v.branch_office_id + LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no + WHERE v.voucher_no = ? + """, voucherNo); + + if (header.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.voucher.not.found", + "Voucher " + voucherNo + " does not exist in the register."); + } + + final Map h = header.get(0); + final List> lineRows = voucherService.findLines(voucherNo); + + BigDecimal totalDebit = BigDecimal.ZERO; + BigDecimal totalCredit = BigDecimal.ZERO; + final List> lines = new java.util.ArrayList<>(); + for (final Map r : lineRows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + final String entryType = (String) r.get("entry_type"); + if ("DR".equals(entryType)) { + totalDebit = totalDebit.add(amount); + } else { + totalCredit = totalCredit.add(amount); + } + + final Map m = new LinkedHashMap<>(); + m.put("lineNo", r.get("line_no")); + m.put("entryType", entryType); + m.put("glAccountId", r.get("gl_account_id")); + m.put("savingsAccountId", r.get("savings_account_id")); + m.put("loanAccountId", r.get("loan_account_id")); + m.put("partyName", r.get("party_name")); + m.put("accountNo", r.get("account_no")); + m.put("amount", amount); + m.put("description", r.get("description")); + lines.add(m); + } + + final Map out = new LinkedHashMap<>(); + out.put("voucherNo", h.get("voucher_no")); + out.put("voucherType", h.get("voucher_type")); + out.put("referenceType", h.get("reference_type")); + out.put("referenceId", h.get("reference_id")); + out.put("amount", h.get("amount")); + out.put("branchOfficeId", h.get("branch_office_id")); + out.put("branchName", h.get("branch_name")); + out.put("createdBy", h.get("created_by_username")); + out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString()); + out.put("verificationStatus", h.get("verification_status")); + out.put("lines", lines); + out.put("totalDebit", totalDebit); + out.put("totalCredit", totalCredit); + // Stated rather than assumed: a voucher whose sides were written before the balance check + // existed, or repaired by hand in the database, should show as unbalanced rather than be + // presented as a valid slip. + out.put("balanced", totalDebit.compareTo(totalCredit) == 0); + return gson.toJson(out); + } + /** * The amount the system holds for a voucher, looked up across all three registers. Returns * null when no register knows the number, which is how callers detect a bad voucher. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java new file mode 100644 index 000000000..a5aa781de --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherLine.java @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.voucher.service; + +import java.math.BigDecimal; + +/** + * One side of one voucher: a debit or a credit against a single account. + * + * The account is named by whichever of the three ids is set — GL, savings or loan. A voucher is + * built by pairing these up, so a GL-to-GL journal, a GL-to-account dividend payout and an + * account-to-account transfer are the same structure with different sides filled in, rather than + * three separate shapes. + * + * partyName and accountNo are carried on the line rather than looked up when the voucher is + * printed: a voucher has to keep showing what was actually posted, even after a member is renamed + * or an account is renumbered. + * + * @param entryType + * {@link VoucherService#ENTRY_DEBIT} or {@link VoucherService#ENTRY_CREDIT} + * @param glAccountId + * set when this side posts against a GL account + * @param savingsAccountId + * set when this side posts against a member's savings account + * @param loanAccountId + * set when this side posts against a loan account + * @param partyName + * what the voucher prints for this side — "Cash in Hand", or the member's name + * @param accountNo + * the GL code or account number as it appears on the slip + * @param amount + * always positive; the side is carried by entryType, not by the sign + * @param description + * optional narration for this line alone + */ +public record VoucherLine(String entryType, Long glAccountId, Long savingsAccountId, Long loanAccountId, String partyName, + String accountNo, BigDecimal amount, String description) { + + /** A debit against a GL account. */ + public static VoucherLine debitGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, glAccountId, null, null, name, code, amount, null); + } + + /** A credit against a GL account. */ + public static VoucherLine creditGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, glAccountId, null, null, name, code, amount, null); + } + + /** A debit against a member's savings account — money leaving the member. */ + public static VoucherLine debitSavings(final Long savingsId, final String party, final String accountNo, + final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, null, savingsId, null, party, accountNo, amount, null); + } + + /** A credit against a member's savings account — money reaching the member. */ + public static VoucherLine creditSavings(final Long savingsId, final String party, final String accountNo, + final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, null, savingsId, null, party, accountNo, amount, null); + } + + /** A debit against a loan account. */ + public static VoucherLine debitLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_DEBIT, null, null, loanId, party, accountNo, amount, null); + } + + /** A credit against a loan account. */ + public static VoucherLine creditLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) { + return new VoucherLine(VoucherService.ENTRY_CREDIT, null, null, loanId, party, accountNo, amount, null); + } + + /** The same line carrying a narration, for the cases where one side needs its own reason. */ + public VoucherLine withDescription(final String text) { + return new VoucherLine(entryType, glAccountId, savingsAccountId, loanAccountId, partyName, accountNo, amount, text); + } + + public boolean isDebit() { + return VoucherService.ENTRY_DEBIT.equals(entryType); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index 32bb2cbd8..9190879ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -35,15 +35,19 @@ import org.springframework.stereotype.Service; /** * Central voucher register (m_voucher). Every maker-checker cash transaction * (clerk withdrawal request, cashier deposit request) gets a voucher number in - * the form PREFIX/BRANCH/DDMM/NNNN — for example CR/001/2108/0001 for a deposit - * and DR/001/2108/0001 for a withdrawal on 21 August at branch 001. + * the form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and + * DR/2108/0001 for a withdrawal on 21 August. * * The trailing counter restarts at 0001 every day, independently for each - * (prefix, branch) pair, so a deposit and a withdrawal at the same branch on the - * same day never share a number, and neither does the same direction at another - * branch. Counters live in m_voucher_sequence and are bumped under a row lock, - * because a daily-reset counter cannot lean on the m_voucher autoincrement PK - * for uniqueness the way the old flat "000001" scheme did. + * prefix, so a deposit and a withdrawal on the same day never share a number. + * The series is bank-wide rather than per-branch: the number names the slip + * side and the day, and nothing else. The office that raised the voucher is + * still recorded, in m_voucher.branch_office_id, so a per-branch register + * remains reportable without the branch appearing in the printed number. + * + * Counters live in m_voucher_sequence and are bumped under a row lock, because + * a daily-reset counter cannot lean on the m_voucher autoincrement PK for + * uniqueness the way the old flat "000001" scheme did. * * The voucher is also stamped onto the resulting savings transaction * (m_savings_account_transaction.voucher_no) once the manager approves and the @@ -56,20 +60,84 @@ public class VoucherService { public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; + public static final String TYPE_REPAYMENT = "REPAYMENT"; + public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING"; public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; + /** A voucher raised directly as a journal, rather than by one of the cash registers. */ + public static final String TYPE_JOURNAL = "JOURNAL"; + + /** The debit side of a voucher line. */ + public static final String ENTRY_DEBIT = "DR"; + /** The credit side of a voucher line. */ + public static final String ENTRY_CREDIT = "CR"; + /** Money into an account — the CR side of the slip. */ public static final String PREFIX_CREDIT = "CR"; /** Money out of an account — the DR side of the slip. */ public static final String PREFIX_DEBIT = "DR"; + /** + * A journal voucher, which has no single side to name it by: a transfer between two GLs is + * neither a receipt nor a payment, and calling it CR or DR would pick one arbitrarily. It + * carries its own daily series for the same reason. + */ + public static final String PREFIX_JOURNAL = "JV"; + + /** A loan disbursement or repayment, keyed by its m_loan_transaction id. */ + public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION"; + + /** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */ + public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY"; private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; + /** + * Set while a register is posting the accounting behind a voucher it has already raised. + * + * Several registers do not write their own ledger entries: a cashier GL deposit, a clerk + * disbursement and a Sponsor DD issue all post theirs by invoking the ordinary journal-entry + * command, and a DD funded from a member's account does it by invoking the ordinary savings + * withdrawal. Each of those inner operations raises a voucher of its own, which here would be a + * second voucher over the same money — and the inner one is the worse of the two, naming a bare + * journal or a plain withdrawal where the outer register names the member and the instrument. + * + * A register wraps its posting in {@link #withOwnVoucher} to say the voucher is already its + * responsibility. A ThreadLocal rather than a request attribute, because the same holds for a + * batch job, which has no request to hang the flag on. + */ + private static final ThreadLocal OUTER_REGISTER_OWNS_VOUCHER = ThreadLocal.withInitial(() -> Boolean.FALSE); + + /** + * Runs {@code posting} with any voucher the inner operation would raise suppressed, for a + * register that has already raised its own. + * + * Restores the previous value rather than clearing it, so nesting — a register posting twice, + * or one posting routine calling another — cannot switch the suppression off early for the + * outer caller. + */ + public T withOwnVoucher(final java.util.function.Supplier posting) { + final boolean previous = OUTER_REGISTER_OWNS_VOUCHER.get(); + OUTER_REGISTER_OWNS_VOUCHER.set(Boolean.TRUE); + try { + return posting.get(); + } finally { + OUTER_REGISTER_OWNS_VOUCHER.set(previous); + } + } + + /** + * Whether the operation now in progress should raise its own voucher, or leave it to a register + * further out that has already raised one. + */ + public boolean shouldRaiseVoucher() { + return !OUTER_REGISTER_OWNS_VOUCHER.get(); + } + /** * Maps a voucher type to its slip-side prefix. A deposit credits the member's * account (CR); a withdrawal and a disbursement both take money out (DR). @@ -78,36 +146,35 @@ public class VoucherService { return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT; } - /** Branch code as it appears in the UI's BRANCH dropdown: office id zero-padded to 3 digits. */ - private static String branchCode(final Long officeId) { - return String.format("%03d", officeId != null ? officeId : 1L); - } - /** - * Reserves the next counter value for one (prefix, branch, day) series. + * Reserves the next counter value for one (prefix, day) series. * * The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use * of the day and increments it thereafter. Being a single statement, it takes * and releases the row lock atomically, so two cashiers submitting at the same * moment are serialised by the database and can never be handed the same * number — no explicit SELECT ... FOR UPDATE needed. + * + * branch_code is written as the empty string rather than dropped: the column is + * NOT NULL from the original schema, and one bank-wide series per (prefix, day) + * means there is no longer a branch to put there. */ - private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) { + private int nextSeriesNumber(final String prefix, final LocalDate day) { return jdbcTemplate.queryForObject(""" INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number) - VALUES (?,?,?,1) - ON CONFLICT (prefix, branch_code, series_date) + VALUES (?,'',?,1) + ON CONFLICT (prefix, series_date) DO UPDATE SET last_number = m_voucher_sequence.last_number + 1 RETURNING last_number """, - Integer.class, prefix, branch, Date.valueOf(day)); + Integer.class, prefix, Date.valueOf(day)); } /** * Creates a voucher row and returns the formatted voucher number, e.g. - * "CR/001/2108/0001". The branch is taken from the acting user's own office - * rather than from any branch the screen happens to have selected, so the - * voucher always names the branch that actually handled the cash. + * "CR/2108/0001". The office that handled the cash is taken from the acting + * user rather than from any branch the screen happens to have selected, and + * is stored alongside the voucher instead of being printed into its number. */ public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user) { @@ -124,28 +191,236 @@ public class VoucherService { public String createVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal amount, final AppUser user, final String prefix) { + return createVoucher(voucherType, referenceType, referenceId, null, amount, user, prefix); + } + + /** + * As above, but for a source identified by text rather than by a number — a manual journal, + * which is known by its transaction id. Exactly one of referenceId and referenceKey is + * expected to be set; a source uses whichever identifier it actually has. + */ + public String createVoucher(final String voucherType, final String referenceType, + final Long referenceId, final String referenceKey, final BigDecimal amount, + final AppUser user, final String prefix) { // Tenant timezone, not the server's — the day the counter resets on has to be the // branch's business day, otherwise a late-evening voucher lands in the wrong series. final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null); - final int serial = nextSeriesNumber(prefix, branch, today); - final String voucherNo = String.format("%s/%s/%s/%04d", - prefix, branch, today.format(DAY_MONTH), serial); + final int serial = nextSeriesNumber(prefix, today); + final String voucherNo = String.format("%s/%s/%04d", + prefix, today.format(DAY_MONTH), serial); + + final Long branchOfficeId = user.getOffice() != null ? user.getOffice().getId() : null; jdbcTemplate.update(""" INSERT INTO m_voucher - (voucher_no, voucher_type, reference_type, reference_id, amount, - created_by, created_by_username, created_at) - VALUES (?,?,?,?,?,?,?,?) + (voucher_no, voucher_type, reference_type, reference_id, reference_key, amount, + branch_office_id, created_by, created_by_username, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?) """, - voucherNo, voucherType, referenceType, referenceId, amount, - user.getId(), user.getUsername(), Timestamp.from(Instant.now()) + voucherNo, voucherType, referenceType, referenceId, referenceKey, amount, + branchOfficeId, user.getId(), user.getUsername(), Timestamp.from(Instant.now()) ); return voucherNo; } + /** + * Creates a voucher together with the debit and credit sides that make it up, and returns the + * formatted voucher number. + * + * This is the form to reach for whenever the posting is known at the point the voucher is + * raised. The amount stored on the voucher is the total of one side, which — the two sides + * being equal — is the value of the voucher as a whole. + * + * @throws IllegalArgumentException + * if the lines do not balance, or do not include both a debit and a credit. A + * voucher that does not balance is not a voucher, and letting one reach the + * register would put the trial balance out by exactly the difference. + */ + public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId, + final AppUser user, final String prefix, final List lines) { + return createVoucherWithLines(voucherType, referenceType, referenceId, null, user, prefix, lines); + } + + /** As above, for a source identified by text rather than by a number. */ + public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId, + final String referenceKey, final AppUser user, final String prefix, final List lines) { + + final BigDecimal total = validateBalanced(lines); + final String voucherNo = createVoucher(voucherType, referenceType, referenceId, referenceKey, total, user, prefix); + writeLines(voucherNo, lines); + return voucherNo; + } + + /** + * Checks that the lines form a voucher, and returns the value of one side. + * + * Debits and credits are compared by {@link BigDecimal#compareTo}, not equals, so that two + * figures differing only in scale — 5000 and 5000.00 — count as the same amount rather than + * failing a voucher that is in fact balanced. + */ + private static BigDecimal validateBalanced(final List lines) { + if (lines == null || lines.isEmpty()) { + throw new IllegalArgumentException("A voucher needs at least one debit and one credit line."); + } + + BigDecimal debits = BigDecimal.ZERO; + BigDecimal credits = BigDecimal.ZERO; + for (final VoucherLine line : lines) { + if (line.amount() == null || line.amount().signum() <= 0) { + throw new IllegalArgumentException("Every voucher line needs a positive amount; the side is carried by DR/CR."); + } + if (line.isDebit()) { + debits = debits.add(line.amount()); + } else if (ENTRY_CREDIT.equals(line.entryType())) { + credits = credits.add(line.amount()); + } else { + throw new IllegalArgumentException("A voucher line must be DR or CR, not '" + line.entryType() + "'."); + } + } + + if (debits.signum() == 0 || credits.signum() == 0) { + throw new IllegalArgumentException("A voucher needs both a debit and a credit side."); + } + if (debits.compareTo(credits) != 0) { + throw new IllegalArgumentException( + "Voucher does not balance: debits " + debits.toPlainString() + " against credits " + credits.toPlainString() + "."); + } + return debits; + } + + /** Writes the sides of an already-created voucher, numbered in the order given. */ + private void writeLines(final String voucherNo, final List lines) { + int lineNo = 1; + for (final VoucherLine line : lines) { + jdbcTemplate.update(""" + INSERT INTO m_voucher_line + (voucher_no, line_no, entry_type, gl_account_id, savings_account_id, loan_account_id, + party_name, account_no, amount, description) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, + voucherNo, lineNo++, line.entryType(), line.glAccountId(), line.savingsAccountId(), + line.loanAccountId(), line.partyName(), line.accountNo(), line.amount(), line.description()); + } + } + + /** + * Attaches sides to a voucher that was already raised without them. + * + * The three cash registers create their voucher when the request is made, but only know the + * accounts involved once it is approved. Rather than hold the voucher number back until then, + * they raise it up front and post the sides here. + */ + public void addLines(final String voucherNo, final List lines) { + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + validateBalanced(lines); + + // Re-posting the sides of a voucher replaces them rather than stacking a second set + // beside the first, so an approval that runs twice cannot double the printed voucher. + jdbcTemplate.update("DELETE FROM m_voucher_line WHERE voucher_no = ?", voucherNo); + writeLines(voucherNo, lines); + } + + /** + * The GL account a savings account's balance is controlled by, as name and code. + * + * A member's deposit does not post against the member directly — it posts against the Savings + * Control account their product maps to. Lives here rather than in each caller because every + * register that touches a savings account needs the same lookup to print its slip. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map savingsControlAccount(final Long savingsAccountId) { + if (savingsAccountId == null) { + return Map.of(); + } + // product_type 2 = savings, financial_account_type 2 = Savings Control. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 + LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(Map.of()); + } + + /** + * The GL account a savings product charges its interest expense to, as name and code. + * + * The society's side of an interest posting: the member's balance is credited, and this is what + * the credit costs the society. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map savingsInterestExpenseAccount(final Long savingsAccountId) { + if (savingsAccountId == null) { + return Map.of(); + } + // product_type 2 = savings, financial_account_type 3 = Interest on Savings. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 3 + LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(Map.of()); + } + + /** + * The GL account a loan's outstanding balance is carried in, as name and code. + * + * The loan counterpart of {@link #savingsControlAccount}: a disbursement or repayment posts + * against the product's Loan Portfolio account, not against the borrower directly. + * + * Returns an empty map when the product has no mapping, so a voucher can still name the side + * without one instead of failing over a configuration gap. + */ + public Map loanPortfolioAccount(final Long loanId) { + if (loanId == null) { + return Map.of(); + } + // product_type 1 = loan, financial_account_type 2 = Loan Portfolio. + return jdbcTemplate.queryForList(""" + SELECT gl.id, gl.name, gl.gl_code + FROM m_loan l + JOIN acc_product_mapping apm ON apm.product_id = l.product_id + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE l.id = ? AND apm.product_type = 1 AND apm.financial_account_type = 2 + LIMIT 1 + """, loanId).stream().findFirst().orElse(Map.of()); + } + + /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + public Map glAccount(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT id, name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + + /** + * The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or + * one whose accounts were never recorded — callers show what they have rather than treating an + * older voucher as broken. + */ + public List> findLines(final String voucherNo) { + return jdbcTemplate.queryForList(""" + SELECT line_no, entry_type, gl_account_id, savings_account_id, loan_account_id, + party_name, account_no, amount, description + FROM m_voucher_line + WHERE voucher_no = ? + ORDER BY line_no + """, voucherNo); + } + /** * Looks up the voucher for the given request and returns its voucher_no, * or null if none exists (e.g. records created before vouchers existed). diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index e39a6cdd6..70fbe79ba 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index f51d9bc71..66e5c3fee 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -1,4 +1,4 @@ - + @@ -574,4 +574,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index ed25176f2..e4b93b87f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -42,4 +42,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml new file mode 100644 index 000000000..8ec2c7378 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0494_drop_dividend_forfeited_income_gl.xml @@ -0,0 +1,63 @@ + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVFORF01' + + + SELECT COUNT(*) FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.gl_code = 'DIVFORF01' + + + + + + DELETE FROM acc_gl_financial_activity_account + WHERE gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = 'DIVFORF01') + + + DELETE FROM acc_gl_account WHERE gl_code = 'DIVFORF01' + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml new file mode 100644 index 000000000..5227695eb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0495_voucher_no_drop_branch.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + UPDATE m_voucher + SET branch_office_id = CAST(SPLIT_PART(voucher_no, '/', 2) AS BIGINT) + WHERE branch_office_id IS NULL + AND voucher_no ~ '^[A-Z]{2,4}/[0-9]{3}/[0-9]{4}/[0-9]{4}$' + + + + + + + + + + + + + + + DELETE FROM m_voucher_sequence s + WHERE EXISTS ( + SELECT 1 FROM m_voucher_sequence t + WHERE t.prefix = s.prefix + AND t.series_date = s.series_date + AND (t.last_number > s.last_number + OR (t.last_number = s.last_number AND t.id < s.id)) + ) + + UPDATE m_voucher_sequence SET branch_code = '' WHERE branch_code IS NOT NULL + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml new file mode 100644 index 000000000..35355a9b5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0496_create_voucher_line.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml new file mode 100644 index 000000000..ef008b32d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0497_voucher_reference_key.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml new file mode 100644 index 000000000..ae1d83229 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0498_add_rate_percentage_to_dividend_payout.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_share_product_dividend_pay_out WHERE rate_percentage IS NOT NULL + + + + UPDATE m_share_product_dividend_pay_out d + SET rate_percentage = ROUND(d.amount / c.weighted_capital * 100, 4) + FROM ( + SELECT p.id, + COALESCE(SUM(t.amount * ((p.dividend_period_end_date + - GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1)), 0) / 365.0 + AS weighted_capital + FROM m_share_product_dividend_pay_out p + LEFT JOIN m_share_account sa ON sa.product_id = p.product_id AND sa.status_enum = 300 + LEFT JOIN m_share_account_transactions t ON t.account_id = sa.id + AND t.type_enum = 500 AND t.is_active = true AND t.status_enum = 300 + AND t.transaction_date <= p.dividend_period_end_date + AND ((p.dividend_period_end_date + - GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1) >= 365 + GROUP BY p.id + ) c + WHERE c.id = d.id AND c.weighted_capital > 0 AND d.rate_percentage IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml new file mode 100644 index 000000000..d2a41a756 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0516_seed_savings_interest_posting_control_gls.xml @@ -0,0 +1,97 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200304' + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200305' + + + + + + + + + + + + + + + + + + + UPDATE acc_gl_account + SET hierarchy = COALESCE((SELECT p.hierarchy FROM acc_gl_account p WHERE p.gl_code = '200300'), '.') || id || '.' + WHERE gl_code IN ('200304', '200305') + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml new file mode 100644 index 000000000..ab7290a59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml new file mode 100644 index 000000000..33482f5f2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_savings_interest_posting_schedule + (savings_product_id, posting_period_enum, run_at_time, is_active) + SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true + FROM m_savings_product sp + WHERE sp.deposit_type_enum = 100 + AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s + WHERE s.savings_product_id = sp.id) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml new file mode 100644 index 000000000..55a4ce667 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0519_post_savings_interest_at_period_end.xml @@ -0,0 +1,64 @@ + + + + + + + + + SELECT COUNT(*) FROM c_configuration + WHERE name = 'savings-interest-posting-current-period-end' AND enabled = false + + + + + + name = 'savings-interest-posting-current-period-end' + + + + From 8ecab08d5739084e22d6276419d39a47367a5e48 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 20:02:50 +0530 Subject: [PATCH 20/55] fix(shares,savings): remove product-config voucher auto-resolve, fix charge/currency edit bugs, gate withdrawal on settlement - ShareAdvanceCollectionApiResource: drop the hardcoded/auto-resolved Share Suspense GL lookup for by-voucher search; GL account and voucher are now both required, manually-selected inputs. apply() now accepts a separately-verified entry-fee receipt (chargeCollectionId) so a fee collected into a different GL than the share value is never swept into the Sundry leg or double-posted to income. - ClerkShareWithdrawalRequestApiResource: hard-block approval when collection_applied is false, so shares nobody has actually paid for can't be redeemed off an Active-but-unsettled account. - ProductPendingEditFieldMapper / ShareProductDataSerializer: fix maker-checker edits on share products silently dropping charges (chargesSelected vs charges field-name mismatch) and NPEing on approval when the replay JSON carries no currencyCode. - SavingsAccount: initialize the clearing-hold field to zero so new savings submissions don't violate the NOT NULL constraint on total_savings_amount_on_hold_clearing. - CashReportApiResource: fix voucher number resolution for GL-type ledger entries. - application.properties: revert local tenant DB password default. Co-Authored-By: Claude Sonnet 5 --- .../cashreport/api/CashReportApiResource.java | 13 ++ .../ShareAdvanceCollectionApiResource.java | 199 +++++++++++++++--- ...lerkShareWithdrawalRequestApiResource.java | 24 ++- .../ShareProductDataSerializer.java | 9 +- ...WritePlatformServiceJpaRepositoryImpl.java | 6 +- .../src/main/resources/application.properties | 4 +- .../domain/ProductPendingEditFieldMapper.java | 27 ++- .../savings/domain/SavingsAccount.java | 2 +- 8 files changed, 238 insertions(+), 46 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index a277822a0..aa73ec92e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -903,10 +903,23 @@ public class CashReportApiResource { // the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/ // withdrawal request at approval time -- std.txn_id on its own is only the internal savings // transaction id (e.g. "4"), never what was actually printed on the voucher. + // + // A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry + // reference) rather than a plain decimal savings-transaction id, but std.txn_id is always + // stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the + // hex string's numeric value (178807899015311), not the hex string itself, and a plain + // string-equality join against cdr.txn_id can never match it. The second half of each OR + // below re-derives that same decimal value from the hex string to match it -- guarded to + // only run on strings that are actually hex (so an ordinary decimal txn_id, which never + // contains a-f, is never sent through the hex cast at all). final String baseGroupSql = "FROM m_savings_txn_denomination std " + "LEFT JOIN m_client mc ON mc.id = std.client_id " + "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + + " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' " + + " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " + "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + + " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' " + + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 8a9de15f2..e5c162e48 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -104,8 +104,6 @@ public class ShareAdvanceCollectionApiResource { // ── m_share_account.status_enum ─────────────────────────────────────── private static final int STATUS_ACTIVE = 300; - // ── GL account the cashier deposits share money into ────────────────── - private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001"; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -213,17 +211,18 @@ public class ShareAdvanceCollectionApiResource { @GET @Path("/by-voucher") public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo, - @QueryParam("clientId") final Long clientIdHint) { + @QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) { if (voucherNo == null || voucherNo.isBlank()) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required", "A voucher or receipt number is required."); } - return findByVoucher(voucherNo, clientIdHint); + return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride); } @GET @Path("/by-voucher/{voucherNo}") - public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) { + public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint, + @QueryParam("glAccountNo") final String glAccountNoOverride) { this.context.authenticatedUser(); // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. @@ -234,15 +233,19 @@ public class ShareAdvanceCollectionApiResource { + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); if (rows.isEmpty()) { - // Cash is normally taken through the cashier's Transaction Entry deposit into the Share - // Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a - // collection so the member's voucher resolves here. - final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint); + // Cash is normally taken through the cashier's Transaction Entry deposit into the GL + // account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt + // that approved deposit as a collection so the member's voucher resolves here. Which GL + // account to look in is never guessed from product configuration — the counter selects it + // by hand every time, since a product mapping can silently go missing or point at the + // wrong account and a plain voucher lookup should never depend on that being right. + final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); if (adopted != null) { return Response.ok(gson.toJson(adopted)).build(); } throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher " - + voucherNo + ". Check the number, or confirm the deposit has been approved by a manager."); + + voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the " + + "deposit has been approved by a manager."); } final Map row = rows.get(0); @@ -253,6 +256,55 @@ public class ShareAdvanceCollectionApiResource { return Response.ok(gson.toJson(row)).build(); } + // ══════════════════════════════════════════════════════════════════════ + // GET — GL accounts selectable for a by-voucher lookup + // ══════════════════════════════════════════════════════════════════════ + /** + * Every active, postable GL account, for the GL account picker on the Apply Collection screen. + * + * Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to + * point at today — that mapping is exactly what this lookup no longer trusts, since it can go + * missing or wrong without anyone noticing until a voucher search fails at the counter. The person + * applying a receipt picks the account the deposit actually went to, every time. + */ + @GET + @Path("/glaccounts") + public Response glAccounts() { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate.queryForList( + "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); + final Map out = new LinkedHashMap<>(); + out.put("glAccounts", rows); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — What a share account needs before a collection can be applied + // ══════════════════════════════════════════════════════════════════════ + /** + * Share value plus any outstanding entry fee for one share account -- shown up front on the Apply + * Collection screen so the person at the counter knows the full amount to collect (and that it may + * come in as more than one receipt, e.g. one for the share value and a separate one for the fee) + * before a search ever comes up short, rather than learning it only from an error after the fact. + */ + @GET + @Path("/requirement") + public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required."); + } + final Map acct = loadAccount(shareAccountId); + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final Map out = new LinkedHashMap<>(); + out.put("accountNo", acct.get("account_no")); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("required", shareAmount.add(chargeAmount)); + return Response.ok(gson.toJson(out)).build(); + } + // ══════════════════════════════════════════════════════════════════════ // POST — Apply a receipt to an activated share account // ══════════════════════════════════════════════════════════════════════ @@ -273,7 +325,8 @@ public class ShareAdvanceCollectionApiResource { // A member who pays in instalments has more than one receipt against the same application: // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they // are applied together as one settlement. The path receipt leads; any others come in the - // body. + // body. These are the SHARE-side receipts only -- money that actually sat in Sundry and + // genuinely needs to move out of it into Share Capital. final List collectionIds = new java.util.ArrayList<>(); collectionIds.add(collectionId); for (final Long additionalId : getLongList(json, "additionalCollectionIds")) { @@ -282,6 +335,14 @@ public class ShareAdvanceCollectionApiResource { } } + // A separately-verified entry-fee receipt, when the member's fee was collected as its own + // receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry. + // This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL + // entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave + // null) when the fee was paid as part of the same Sundry receipt(s) as the share value -- + // that legacy, single-receipt case still needs the fee amount swept out of Sundry below. + final Long chargeCollectionId = getLong(json, "chargeCollectionId"); + final List> collections = new java.util.ArrayList<>(); for (final Long id : collectionIds) { final Map row = loadCollection(id); @@ -292,6 +353,15 @@ public class ShareAdvanceCollectionApiResource { collections.add(row); } + Map chargeColl = null; + if (chargeCollectionId != null) { + chargeColl = loadCollection(chargeCollectionId); + if (!"UNAPPLIED".equals(chargeColl.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied", + "Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + "."); + } + } + final Map coll = collections.get(0); final Map acct = loadAccount(shareAccountId); @@ -306,6 +376,10 @@ public class ShareAdvanceCollectionApiResource { + " belongs to a different member than share account " + acct.get("account_no") + "."); } } + if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt " + + chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + "."); + } final int statusEnum = toInt(acct.get("status_enum")); if (statusEnum != STATUS_ACTIVE) { @@ -320,7 +394,12 @@ public class ShareAdvanceCollectionApiResource { // Share value the account was opened for, and any entry fee attached to it. final BigDecimal shareAmount = shareValueOf(shareAccountId); final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); + + // When the fee is being verified as its own receipt, the Sundry-side receipts only need to + // cover the share value -- the fee is proved paid separately below, and its money is never + // expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is + // assumed bundled into the same Sundry receipt(s) as the share value, exactly as before. + final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount); BigDecimal receiptAmount = BigDecimal.ZERO; final StringBuilder receiptNos = new StringBuilder(); @@ -335,23 +414,41 @@ public class ShareAdvanceCollectionApiResource { // Short is blocked: share capital cannot be part-issued — the member either bought those // shares or did not — so settling for less would leave the account short by an amount owed // by nobody. The clerk collects the balance on another receipt and applies both together. - if (receiptAmount.compareTo(required) < 0) { - final BigDecimal shortfall = required.subtract(receiptAmount); + if (receiptAmount.compareTo(requiredForShareReceipts) < 0) { + final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount); throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short", "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires " - + required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall + + requiredForShareReceipts + ". Collect the remaining " + shortfall + " from the member, then apply both receipts together."); } + // The fee receipt, when given, must cover the fee on its own -- checked separately from the + // share-side shortfall above, since the two are no longer one combined pot. + if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount"); + if (chargeReceiptAmount.compareTo(chargeAmount) < 0) { + final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount); + throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short", + "Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account " + + acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining " + + shortfall + " from the member."); + } + } + // Over-payment is allowed through rather than turning a member away over a rounded-up note. - // The difference is credited to their linked savings account below. - final BigDecimal excessAmount = receiptAmount.subtract(required); + // The difference is credited to their linked savings account below. Only the share-side + // excess is handled here -- an over-payment on a separately-verified fee receipt would need + // its own GL leg to fund the savings credit, which this flow does not post (that receipt's + // money is left exactly where the cashier already put it), so it is left uncollected rather + // than silently unbalancing the ledger. + final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts); final Long savingsAccountId = toLong(acct.get("savings_account_id")); if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) { throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings", "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") - + " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings " - + "account, but none is linked to this share account. Link one, or collect the exact amount."); + + " requires only " + requiredForShareReceipts + ". The extra " + excessAmount + + " must be credited to the member's savings account, but none is linked to this share account. Link one, or " + + "collect the exact amount."); } final Long officeId = toLong(acct.get("office_id")); @@ -367,8 +464,9 @@ public class ShareAdvanceCollectionApiResource { final String txnId = "SHAPP-" + System.nanoTime(); final String accountNo = (String) acct.get("account_no"); - // Dr Sundry for everything collected, including any over-payment — the liability those - // receipts created is cleared in full, in one leg, so nothing is left behind in suspense. + // Dr Sundry for the share-side receipts, including any over-payment on them — the liability + // those receipts created is cleared in full. A verified fee receipt is deliberately excluded: + // it was never in Sundry, so nothing here should debit Sundry for it. insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); @@ -376,14 +474,19 @@ public class ShareAdvanceCollectionApiResource { insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share capital issued for " + accountNo); - // Cr Income from Fees for the entry fee, when one was charged. + // Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt + // above -- that money already sits in whichever GL the cashier deposited it to (often the fee + // income account itself), so moving it again here would double it. When it was bundled into + // the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs + // to move, exactly as before. if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { - // Resolved from the share product's "Income from Fees" mapping, which points at the - // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. - final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); - insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share entry fee for " + accountNo); - + if (chargeColl == null) { + // Resolved from the share product's "Income from Fees" mapping, which points at the + // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share entry fee for " + accountNo); + } jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); } @@ -399,23 +502,39 @@ public class ShareAdvanceCollectionApiResource { } // Stamp the originating cashier deposits so the same vouchers cannot be applied twice. + // Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit + // table regardless of which GL account they were posted to, so no GL account lookup is needed + // here either. Covers the fee receipt's own deposit too, when one was verified. for (final Map row : collections) { if (row.get("voucher_no") != null) { jdbcTemplate.update( "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " - + " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL", - shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO); + + " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no")); } } + if (chargeColl != null && chargeColl.get("voucher_no") != null) { + jdbcTemplate.update( + "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " + + " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no")); + } // Every receipt in this settlement is marked applied, not just the one in the path — they - // were all consumed by it, and an unmarked one would still show as money awaiting use. + // were all consumed by it, and an unmarked one would still show as money awaiting use. The fee + // receipt is marked applied too, even though it posted no GL entry of its own. for (final Long id : collectionIds) { jdbcTemplate.update( "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); } + if (chargeCollectionId != null) { + jdbcTemplate.update( + "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId); + } // Reveals the purchase transaction in the passbook and transactions views. jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); @@ -428,7 +547,8 @@ public class ShareAdvanceCollectionApiResource { out.put("accountNo", accountNo); out.put("shareAmount", shareAmount); out.put("chargeAmount", chargeAmount); - out.put("totalApplied", required); + out.put("chargeReceiptVerifiedSeparately", chargeColl != null); + out.put("totalApplied", requiredForShareReceipts); out.put("totalCollected", receiptAmount); out.put("glTransactionId", txnId); out.put("status", "APPLIED"); @@ -458,16 +578,25 @@ public class ShareAdvanceCollectionApiResource { * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which * is what keeps the money tied to the right member. * - * @return the newly created collection row, or null when no approved Sundry deposit matches. + * @param glAccountNoOverride + * required -- the GL account number the deposit was posted to, selected by the counter + * rather than resolved from any product's accounting configuration, so a broken or + * missing product mapping can never make a genuine voucher unreachable. + * @return the newly created collection row, or null when no approved deposit matches. */ - private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) { + private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) { + if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required", + "Select the GL account this voucher was deposited to, then search again."); + } + final String shareSundryAccountNo = glAccountNoOverride.trim(); final List> deposits = jdbcTemplate .queryForList( "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + " FROM m_cashier_deposit_request d" + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", - voucherNo, SHARE_SUNDRY_ACCOUNT_NO); + voucherNo, shareSundryAccountNo); if (deposits.isEmpty()) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 30dbe7b23..6301a771a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -324,7 +324,7 @@ public class ClerkShareWithdrawalRequestApiResource { final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); final Map shareAccountRow = jdbcTemplate.queryForList(""" - SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, + SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied, savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId FROM m_share_account WHERE id = ? """, shareAccountId).stream().findFirst().orElse(null); @@ -340,6 +340,14 @@ public class ClerkShareWithdrawalRequestApiResource { : -1; checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus))); + // Active only means approved and activated -- not paid for. Real share capital is only posted + // once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and + // approve() below hard-blocks on this same flag, so a request failing this check can never + // actually be approved regardless of what a checker decides here. + final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied")); + checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied, + collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet")); + final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null ? ((Number) shareAccountRow.get("savingsAccountId")).longValue() : null; @@ -589,6 +597,20 @@ public class ClerkShareWithdrawalRequestApiResource { final Object meetingDateObj = record.get("meeting_date"); final String resolutionNumber = (String) record.get("resolution_number"); + // Account creation, approval and activation post no share-capital GL of their own -- a share + // account can be Active with "approved" shares that nobody has actually paid for yet, and only + // gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see + // ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was + // never issued, so this is a hard block here, not just an advisory line in validate()'s + // checklist -- a checker approving off that checklist alone has no other backstop. + final Boolean collectionApplied = jdbcTemplate + .queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream() + .findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null); + if (!Boolean.TRUE.equals(collectionApplied)) { + throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been " + + "applied to it, so these shares have not actually been paid for and cannot be redeemed."); + } + final JsonObject payload = new JsonObject(); payload.addProperty("locale", "en"); payload.addProperty("dateFormat", "dd MMMM yyyy"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java index fa7bc44c1..10d81adf5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java @@ -395,7 +395,14 @@ public class ShareProductDataSerializer { } if (this.fromApiJsonHelper.parameterExists(ShareProductApiConstants.charges_paramname, element)) { - final String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element); + // Falls back to the product's own current currency when the update payload doesn't carry + // one -- a maker-checker edit that only touched charges replays a JSON containing just the + // submitted fields, with no currencyCode of its own, so requiring it here would make every + // charges-only edit crash rather than validate against the currency the product already has. + String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element); + if (currencyCode == null && product.getCurrency() != null) { + currencyCode = product.getCurrency().getCode(); + } Set charges = assembleListOfProductCharges(element, currencyCode); if (product.setCharges(charges)) { actualChanges.put(ShareProductApiConstants.charges_paramname, charges); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index ef128c757..bbfa1822a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -865,7 +865,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, fieldName)); } - final List chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command); + final List chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command, ShareProductApiConstants.charges_paramname); if (chargeIds != null) { this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "charges")); for (final Long chargeId : chargeIds) { @@ -907,7 +907,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } if (submittedFields.contains("charges")) { ProductPendingEditFieldMapper.writeChargesToJson(root, - this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); + this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname); } if (submittedFields.contains("glArrayMappings")) { ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, @@ -944,7 +944,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } if (submittedFields.contains("charges")) { ProductPendingEditFieldMapper.writeChargesToJson(root, - this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); + this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname); } if (submittedFields.contains("glArrayMappings")) { ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 70fbe79ba..e39a6cdd6 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index c6f50ba70..e7cfd0d76 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -547,10 +547,21 @@ public final class ProductPendingEditFieldMapper { /** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */ public static List extractChargeIds(final JsonCommand command) { - if (!command.hasParameter("charges")) { + return extractChargeIds(command, "charges"); + } + + /** + * Same as {@link #extractChargeIds(JsonCommand)}, but reads a caller-chosen JSON field instead of the + * "charges" every other product type uses. Share products alone submit this array under + * {@code chargesSelected} (see {@code ShareProductApiConstants.charges_paramname}) -- calling the + * default overload for a share product silently finds nothing, since {@code hasParameter("charges")} + * is always false for that payload. + */ + public static List extractChargeIds(final JsonCommand command, final String paramName) { + if (!command.hasParameter(paramName)) { return null; } - final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges"); + final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(paramName); final List ids = new java.util.ArrayList<>(); if (raw != null && raw.isJsonArray()) { for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) { @@ -563,13 +574,23 @@ public final class ProductPendingEditFieldMapper { } public static void writeChargesToJson(final JsonObject root, final List charges) { + writeChargesToJson(root, charges, "charges"); + } + + /** + * Same as {@link #writeChargesToJson(JsonObject, List)}, but writes under a caller-chosen field name. + * The replayed JSON built here is fed straight back into the same product-update parser the original + * submission went through, so the field name must match whatever that product type's own submit + * payload used -- {@code chargesSelected} for share products, {@code charges} for every other type. + */ + public static void writeChargesToJson(final JsonObject root, final List charges, final String paramName) { final JsonArray array = new JsonArray(); for (final ProductPendingEditCharge charge : charges) { final JsonObject entry = new JsonObject(); entry.addProperty("id", charge.getChargeId()); array.add(entry); } - root.add("charges", array); + root.add(paramName, array); } // ---- accountFacilityLimits (1:many, satellite table) ------------------------------------------- diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 414134b27..5c6eea230 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -355,7 +355,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures * rather than one bucket doing double duty. */ @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmountClearing; + private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO; @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) protected List identifiers = new ArrayList<>(); From 1130a482a073bcb6df4d1b83349157484cdd8dbf Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 20:37:33 +0530 Subject: [PATCH 21/55] feat(shares): centralized Additional Shares approval queue, remove unused Share Savings Debit - Add ShareAdditionRequestsApiResource (/v1/shareadditionrequests): lists every pending additional-share tranche across all accounts, with a per-tranche detail view carrying the route-specific proof of funding a list row can't show (which voucher(s) fund a cash tranche, or which savings account a savings tranche already debited). Approval/rejection still go through Fineract's own approveadditionalshares/rejectadditionalshares commands unchanged -- this is a read side only, so the existing settle()/unwind() hooks fire from exactly one code path. - Remove ShareSavingsDebitRequestApiResource and its m_share_savings_debit_request table (migration 0520): confirmed zero rows on both tenants since it was built, and superseded by Additional Shares' own savings-funding route, which values a tranche on its own rather than by summing the account's entire holding history. - Update comments in ShareAccountWritePlatformServiceJpaRepositoryImpl, ShareAccountDataSerializer, AdditionalShareTrancheFundingService and AdditionalSharesFundingApiResource that referenced the removed class/endpoint. Co-Authored-By: Claude Sonnet 5 --- .../ShareSavingsDebitRequestApiResource.java | 629 ------------------ .../AdditionalSharesFundingApiResource.java | 11 +- .../api/ShareAdditionRequestsApiResource.java | 196 ++++++ .../ShareAccountDataSerializer.java | 9 +- .../AdditionalShareTrancheFundingService.java | 11 +- ...WritePlatformServiceJpaRepositoryImpl.java | 14 +- .../tenant/final-changelog-tenant.xml | 1 + .../0520_drop_share_savings_debit_request.xml | 40 ++ 8 files changed, 255 insertions(+), 656 deletions(-) delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java deleted file mode 100644 index 0cc431725..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java +++ /dev/null @@ -1,629 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.cashtransfer.api; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.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 jakarta.ws.rs.core.Response; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.apache.fineract.commands.domain.CommandWrapper; -import org.apache.fineract.commands.service.CommandWrapperBuilder; -import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * Settles a share application out of the member's own savings account. - * - * The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks - * in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks - * with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly: - * - *
- *   Dr  Savings Control     (share value + entry fee)
- *       Cr  Share Sundry        (share value + entry fee)
- *
- *   Dr  Share Sundry        (share value + entry fee)
- *       Cr  Share Capital       (share value)
- *       Cr  Entry Fee Income    (charge, when one is attached)
- * 
- * - * Both steps are posted together on approval, so Sundry nets to zero for the settlement and holds - * nothing afterwards. It is still routed through rather than around: every other way money reaches - * share capital — the cashier's advance collection, and both additional-share routes — passes through - * Sundry, and that balance is what the share ledger reconciles against. - * - * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval - * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair - * of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves. - * - * The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and - * running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own - * Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and - * replaced with the share-capital pair above. - */ -@Path("/v1/sharesavingsdebit") -@Component -@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.") -@RequiredArgsConstructor -@Consumes({ MediaType.APPLICATION_JSON }) -@Produces({ MediaType.APPLICATION_JSON }) -public class ShareSavingsDebitRequestApiResource { - - // Fineract convention: 1 = CREDIT, 2 = DEBIT. - private static final int CREDIT = 1; - private static final int DEBIT = 2; - - // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource. - /** Share Sundry — the holding account the member's money passes through on its way to capital. */ - private static final int FA_SHARES_SUSPENSE = 2; - private static final int FA_INCOME_FROM_FEES = 3; - private static final int FA_SHARES_EQUITY = 4; - private static final int PRODUCT_TYPE_SHARES = 4; - - /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ - private static final int PRODUCT_TYPE_SAVINGS = 2; - private static final int FA_SAVINGS_CONTROL = 2; - - private static final int SHARE_STATUS_ACTIVE = 300; - private static final int SAVINGS_STATUS_ACTIVE = 300; - - /** m_payment_type.value seeded by changelog 0483 for this settlement. */ - private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; - - private final PlatformSecurityContext context; - private final JdbcTemplate jdbcTemplate; - private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); - - // ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ────────────── - - /** - * Whether this share account can be settled from savings, and for how much. - * - * Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked, - * does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the - * counter instead of after a request has been raised and has to be rejected. - */ - @GET - @Path("/eligibility/{shareAccountId}") - public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) { - this.context.authenticatedUser(); - final Map acct = loadShareAccount(shareAccountId); - - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - - final Map out = new LinkedHashMap<>(); - out.put("shareAccountId", shareAccountId); - out.put("shareAccountNo", acct.get("account_no")); - out.put("shareAmount", shareAmount); - out.put("chargeAmount", chargeAmount); - out.put("totalRequired", required); - - final Object savingsAccountIdRaw = acct.get("savings_account_id"); - if (savingsAccountIdRaw == null) { - out.put("eligible", false); - out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead."); - return Response.ok(gson.toJson(out)).build(); - } - final Long savingsAccountId = toLong(savingsAccountIdRaw); - final Map savings = loadSavingsAccount(savingsAccountId); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - final BigDecimal available = balance == null ? BigDecimal.ZERO : balance; - - out.put("savingsAccountId", savingsAccountId); - out.put("savingsAccountNo", savings.get("account_no")); - out.put("savingsBalance", available); - - final String blocker = settlementBlocker(acct, savings, required, available); - out.put("eligible", blocker == null); - out.put("reason", blocker); - return Response.ok(gson.toJson(out)).build(); - } - - /** - * Why this share account cannot be settled from savings right now, or null when it can. - * - * Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement - * is allowed. - */ - private String settlementBlocker(final Map shareAccount, final Map savings, - final BigDecimal required, final BigDecimal available) { - - if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) { - return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled."; - } - if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) { - return "Share account " + shareAccount.get("account_no") + " has already been settled."; - } - if (required.compareTo(BigDecimal.ZERO) <= 0) { - return "There is nothing outstanding on share account " + shareAccount.get("account_no") + "."; - } - // The savings account must belong to the same member — debiting anyone else's balance for - // these shares would be taking one member's money for another's holding. - if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) { - return "The linked savings account belongs to a different member."; - } - if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { - return "Savings account " + savings.get("account_no") + " is not Active."; - } - if (available.compareTo(required) < 0) { - return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required - + " is required. Take a deposit first, or settle by cash collection."; - } - return null; - } - - // ── POST /v1/sharesavingsdebit ── Clerk records the request ───────────── - - @POST - @Transactional - public Response submit(final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - final JsonObject json = parseBody(body); - final Long shareAccountId = getLong(json, "shareAccountId"); - if (shareAccountId == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required."); - } - - final Map acct = loadShareAccount(shareAccountId); - final Object savingsAccountIdRaw = acct.get("savings_account_id"); - if (savingsAccountIdRaw == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings", - "Share account " + acct.get("account_no") + " has no linked savings account."); - } - final Long savingsAccountId = toLong(savingsAccountIdRaw); - final Map savings = loadSavingsAccount(savingsAccountId); - - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - - // Checked at submit as well as at approval: the clerk should be told now, not after the - // member has left and a manager finds the account short. - final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); - if (blocker != null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); - } - - final Long existingPending = this.jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class, - shareAccountId); - if (existingPending != null && existingPending > 0) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending", - "A savings-debit request for this share account is already pending review."); - } - - final Long newId = this.jdbcTemplate.queryForObject(""" - INSERT INTO m_share_savings_debit_request - (share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount, - currency_code, status, clerk_id, clerk_name, note) - VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?) - RETURNING id - """, Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required, - acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note")); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", newId); - out.put("status", "PENDING"); - out.put("totalAmount", required); - return Response.ok(gson.toJson(out)).build(); - } - - // ── GET /v1/sharesavingsdebit ── Requests awaiting review ─────────────── - - @GET - public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status, - @QueryParam("shareAccountId") final Long shareAccountId) { - this.context.authenticatedUser(); - - final StringBuilder sql = new StringBuilder(""" - SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name, - r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance, - r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status, - r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at, - r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note - FROM m_share_savings_debit_request r - JOIN m_share_account sa ON sa.id = r.share_account_id - JOIN m_client c ON c.id = r.client_id - LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id - WHERE 1 = 1 - """); - final List params = new ArrayList<>(); - if (!"ALL".equalsIgnoreCase(status)) { - sql.append(" AND r.status = ? "); - params.add(status.toUpperCase()); - } - if (shareAccountId != null) { - sql.append(" AND r.share_account_id = ? "); - params.add(shareAccountId); - } - sql.append(" ORDER BY r.submitted_at DESC, r.id DESC"); - - return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build(); - } - - // ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ───────── - - /** - * Debits the member's savings and issues the shares. - * - * Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the - * approval is the point at which it actually matters. - */ - @POST - @Path("/{id}/approve") - @Transactional - public Response approve(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); - validateCheckerRole(currentUser); - - final Map req = loadRequest(id); - if (!"PENDING".equals(req.get("status"))) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", - "Only PENDING requests can be approved. This one is " + req.get("status") + "."); - } - if (toLong(req.get("clerk_id")).equals(currentUser.getId())) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval", - "Maker-checker: you cannot approve a request you submitted yourself. Ask another manager."); - } - - final Long shareAccountId = toLong(req.get("share_account_id")); - final Long savingsAccountId = toLong(req.get("savings_account_id")); - final Map acct = loadShareAccount(shareAccountId); - final Map savings = loadSavingsAccount(savingsAccountId); - - // Recomputed rather than taken from the request: shares or charges may have been edited - // between submit and approval, and the member must be debited for what is actually owed. - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - - final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); - if (blocker != null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); - } - - final Long productId = toLong(acct.get("product_id")); - final Long officeId = toLong(acct.get("office_id")); - final String currencyCode = (String) acct.get("currency_code"); - final String accountNo = (String) acct.get("account_no"); - - final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); - final Long glSavingsControl = savingsControlGlOf(savingsAccountId); - if (glSavingsControl == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped", - "The Savings Control GL is not mapped for this savings account's product."); - } - - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final String note = "Share application settled from savings | " + accountNo; - - // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL - // pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money - // became share capital, which the pair below records. - final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note); - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); - - final long userId = currentUser.getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHSAV-" + savingsTxnId; - - // Routed through Share Sundry rather than straight from savings into share capital, in two - // steps posted together: - // - // 1. Dr Savings Control / Cr Share Sundry — the money leaves the member - // 2. Dr Share Sundry / Cr Share Capital (+ Cr Fee Income) — and is issued as shares - // - // Every other way money reaches share capital passes through Sundry — the cashier's advance - // collection and both additional-share routes — so the Sundry balance is what the whole - // share ledger reconciles against. A settlement that jumped the queue straight to capital - // left that reconciliation permanently short by its own amount, and left no single account - // where money taken for shares but not yet issued could be seen. - final Long glSundry = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); - - insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share application debited from savings " + savings.get("account_no") + " | " + accountNo); - insertEntry(officeId, glSundry, CREDIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share application held in Share Sundry | " + accountNo); - - // The full amount comes back out of Sundry — share value to capital, entry fee to income — - // so Sundry nets to zero for this settlement rather than retaining the fee. - insertEntry(officeId, glSundry, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share Sundry released for " + accountNo); - insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share capital issued for " + accountNo); - - if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { - final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); - insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share entry fee for " + accountNo); - this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " - + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); - } - - // Marks the account settled, which is what stops it being settled again by either route. - this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); - - this.jdbcTemplate.update(""" - UPDATE m_share_savings_debit_request - SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), - share_amount = ?, charge_amount = ?, total_amount = ?, - savings_transaction_id = ?, gl_transaction_id = ? - WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", id); - out.put("status", "APPROVED"); - out.put("shareAmount", shareAmount); - out.put("chargeAmount", chargeAmount); - out.put("totalDebited", required); - out.put("savingsTransactionId", savingsTxnId); - return Response.ok(gson.toJson(out)).build(); - } - - // ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ─────────── - - @POST - @Path("/{id}/reject") - @Transactional - public Response reject(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - validateCheckerRole(currentUser); - - final Map req = loadRequest(id); - if (!"PENDING".equals(req.get("status"))) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", - "Only PENDING requests can be rejected. This one is " + req.get("status") + "."); - } - - final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body); - this.jdbcTemplate.update(""" - UPDATE m_share_savings_debit_request - SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? - WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", id); - out.put("status", "REJECTED"); - return Response.ok(gson.toJson(out)).build(); - } - - // ── Helpers ──────────────────────────────────────────────────────────── - - private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, - final String note) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", txnDate.toString()); - payload.addProperty("transactionAmount", amount); - payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); - payload.addProperty("note", note); - - final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) - .build(); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId(); - } - - /** - * The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the - * withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out. - * The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it - * on the member's statement and in payment-type reporting. - * - * Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles - * rather than failing at approval with money already half-moved. - */ - private Long resolveSettlementPaymentTypeId() { - final List> rows = this.jdbcTemplate - .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE); - if (!rows.isEmpty()) { - return toLong(rows.get(0).get("id")); - } - final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1"); - if (fallback.isEmpty()) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type", - "No payment types are configured. Add one before settling a share application from savings."); - } - return toLong(fallback.get(0).get("id")); - } - - /** Savings Control GL for this savings account's product. */ - private Long savingsControlGlOf(final Long savingsAccountId) { - final List> rows = this.jdbcTemplate.queryForList(""" - SELECT apm.gl_account_id FROM m_savings_account sa - JOIN acc_product_mapping apm ON apm.product_id = sa.product_id - WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 - """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); - return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); - } - - /** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */ - private BigDecimal shareValueOf(final Long shareAccountId) { - final BigDecimal total = this.jdbcTemplate - .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " - + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); - return total == null ? BigDecimal.ZERO : total; - } - - /** Entry fee still outstanding, zero when no charge was attached. */ - private BigDecimal outstandingChargeOf(final Long shareAccountId) { - final BigDecimal total = this.jdbcTemplate.queryForObject( - "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge " - + " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0", - BigDecimal.class, shareAccountId); - return total == null ? BigDecimal.ZERO : total; - } - - private Map loadShareAccount(final Long shareAccountId) { - try { - return this.jdbcTemplate.queryForMap(""" - SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, - sa.collection_applied, sa.savings_account_id, c.office_id - FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ? - """, shareAccountId); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found", - "Share account " + shareAccountId + " does not exist."); - } - } - - private Map loadSavingsAccount(final Long savingsAccountId) { - try { - return this.jdbcTemplate.queryForMap( - "SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", - savingsAccountId); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found", - "Savings account " + savingsAccountId + " does not exist."); - } - } - - private Map loadRequest(final Long id) { - try { - return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found", - "Savings-debit request " + id + " does not exist."); - } - } - - private Long requireGl(final Long productId, final int financialAccountType, final String label) { - try { - final Long glId = this.jdbcTemplate.queryForObject( - "SELECT gl_account_id FROM acc_product_mapping " - + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", - Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); - if (glId == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", - label + " GL is not mapped for share product " + productId + "."); - } - return glId; - } catch (final IncorrectResultSizeDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", - label + " GL is not mapped for share product " + productId + "."); - } - } - - private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, - final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, - final String description) { - this.jdbcTemplate.update( - "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " - + " entry_date, type_enum, amount, description, entity_id, " - + " created_by, last_modified_by, created_date, lastmodified_date, " - + " is_running_balance_calculated, office_running_balance, " - + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) " - + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", - glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, - userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); - } - - /** - * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. - * - * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as - * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's - * own zone first, which is the zone {@code now} was taken in. - */ - private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { - return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() - .withOffsetSameInstant(ZoneOffset.UTC); - } - - private void validateCheckerRole(final AppUser user) { - if (user.hasAnyPermission("ALL_FUNCTIONS")) { - return; - } - final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) - .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); - if (!isChecker) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required", - "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request."); - } - } - - private JsonObject parseBody(final String body) { - if (body == null || body.isBlank()) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required."); - } - try { - return JsonParser.parseString(body).getAsJsonObject(); - } catch (final RuntimeException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON."); - } - } - - private Long getLong(final JsonObject json, final String field) { - return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; - } - - private String getString(final JsonObject json, final String field) { - if (!json.has(field) || json.get(field).isJsonNull()) { - return null; - } - final String value = json.get(field).getAsString().trim(); - return value.isEmpty() ? null : value; - } - - private Long toLong(final Object o) { - return o instanceof Number n ? n.longValue() : null; - } - - private int toInt(final Object o) { - return o instanceof Number n ? n.intValue() : 0; - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java index 709959d15..1b6345cc4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java @@ -45,14 +45,9 @@ import org.springframework.stereotype.Component; * Can a member fund a new additional-share tranche from their savings, and for how much. * *

- * Distinct from {@code /sharesavingsdebit/eligibility}, which answers the same question for an account's first - * application: that endpoint values the settlement by summing every purchase row on the account and treats - * {@code collection_applied} as the account's one settlement. Applied to a further tranche it would quote the member's - * entire holding history and report an already-settled account as ineligible. - * - *

- * Here the amount is the caller's — the tranche is not saved yet, so there is no row to read it from. The share count is - * passed in and priced at the product's current market price, which is the same figure the apply form shows the member. + * Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the + * caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and + * priced at the product's current market price, which is the same figure the apply form shows the member. */ @Path("/v1/shareaccounts/{accountId}/additionalshares") @Component diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java new file mode 100644 index 000000000..66d369a66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java @@ -0,0 +1,196 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue. + * + *

+ * A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page + * (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button + * there) — there was no single place a checker could see every tranche waiting across the whole membership. This + * resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with + * everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which + * voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited. + * + *

+ * Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own + * {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the + * journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService} + * settle/unwind hooks that already run on those commands run exactly once, from exactly one code path. + */ +@Path("/v1/shareadditionrequests") +@Component +@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareAdditionRequestsApiResource { + + /** m_share_account_transactions.status_enum for "submitted, awaiting approval". */ + private static final int STATUS_APPLIED = 100; + /** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */ + private static final int TYPE_PURCHASED = 500; + + private static final String FUNDING_CASH = "CASH"; + private static final String FUNDING_SAVINGS = "SAVINGS"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so + // queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and + // leave the client reading shareAccountId as undefined. + private static final String BASE_SELECT = """ + SELECT t.id AS "transactionId", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + t.transaction_date AS "transactionDate", + t.total_shares AS "totalShares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.resolution_number AS "resolutionNumber", + t.meeting_date AS "meetingDate", + t.funding_source AS "fundingSource", + t.advance_collection_id AS "advanceCollectionId", + t.additional_collection_ids AS "additionalCollectionIds" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + JOIN m_office o ON o.id = c.office_id + JOIN m_share_product sp ON sp.id = sa.product_id + """; + + @GET + @Operation(summary = "List additional-share tranches awaiting approval", + description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.") + public Response retrievePending() { + this.context.authenticatedUser(); + final List> rows = this.jdbcTemplate.queryForList( + BASE_SELECT + " WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = ? " + + " ORDER BY t.transaction_date, t.id", + STATUS_APPLIED, TYPE_PURCHASED); + return Response.ok(gson.toJson(rows)).build(); + } + + @GET + @Path("/{transactionId}") + @Operation(summary = "Full detail for one pending tranche", + description = "The list row plus route-specific proof of funding: the voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.") + public Response retrieveOne(@PathParam("transactionId") final Long transactionId) { + this.context.authenticatedUser(); + + final Map row; + try { + row = this.jdbcTemplate.queryForMap(BASE_SELECT + " WHERE t.id = ?", transactionId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareaddition.not.found", + "Additional-share request " + transactionId + " does not exist."); + } + + final Map out = new LinkedHashMap<>(row); + final String fundingSource = (String) row.get("fundingSource"); + + if (FUNDING_CASH.equals(fundingSource)) { + out.put("vouchers", vouchersFor(row)); + } else if (FUNDING_SAVINGS.equals(fundingSource)) { + out.put("savingsAccount", savingsAccountFor(toLong(row.get("shareAccountId")))); + } + + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Every receipt funding a cash tranche — the lead voucher plus any further instalments — so a checker sees the + * whole pot behind the amount rather than just the first receipt. + */ + private List> vouchersFor(final Map row) { + final List ids = new ArrayList<>(); + final Long lead = toLong(row.get("advanceCollectionId")); + if (lead != null) { + ids.add(lead); + } + final String additional = (String) row.get("additionalCollectionIds"); + if (additional != null && !additional.isBlank()) { + for (final String part : additional.split(",")) { + final String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + ids.add(Long.valueOf(trimmed)); + } + } + } + if (ids.isEmpty()) { + return Collections.emptyList(); + } + final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + return this.jdbcTemplate.queryForList(""" + SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate", + collected_by_name AS "collectedByName", status + FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id + """.formatted(placeholders), ids.toArray()); + } + + /** The savings account a savings-route tranche already debited, so a checker can see it was real money. */ + private Map savingsAccountFor(final Long shareAccountId) { + try { + return this.jdbcTemplate.queryForMap(""" + SELECT sav.id, sav.account_no AS "accountNo", sav.account_balance_derived AS "balance" + FROM m_savings_account sav + JOIN m_share_account sa ON sa.savings_account_id = sav.id + WHERE sa.id = ? + """, shareAccountId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 81229370f..30a3a348d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -658,17 +658,16 @@ public class ShareAccountDataSerializer { private void handlechargesOnActivation(final ShareAccount account) { // The entry fee is deliberately NOT marked paid here. Activating the account decides the - // member may hold shares; it does not collect anything, and nobody has yet said where the - // money comes from — a cash receipt at the counter, or a debit from the member's savings. + // member may hold shares; it does not collect anything, and nobody has yet said the cash + // receipt at the counter has actually been taken. // // Marking it paid at activation left amount_paid_derived at the full fee and // amount_outstanding_derived at zero while nothing had actually been received, so the // settlement step read the fee as already settled and asked the member for the share value // alone ("shares 400.00 + charges 0"). The fee was then never collected at all. // - // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource - // (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set - // amount_paid_derived and amount_outstanding_derived alongside the flag. + // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource, + // which sets amount_paid_derived and amount_outstanding_derived alongside the flag. // // The charge transaction is still dated to the activation date — that records when the fee // became due, which is a separate thing from when it was paid. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index 366f3bf16..d39cc5764 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -47,12 +47,11 @@ import org.springframework.stereotype.Component; * account, where it waits for the tranche to be approved. * *

- * Why this is separate from the account-level settlement resources. {@code ShareAdvanceCollectionApiResource} and - * {@code ShareSavingsDebitRequestApiResource} settle an account's first application. Both value the settlement by - * summing every purchase row on the account, and both guard with account-wide state — {@code collection_applied}, and - * one-pending-request-per-account. Neither assumption survives a second tranche: the valuation would charge the member - * for their entire holding history, and the guard would refuse the request outright. So tranche funding is valued and - * recorded per transaction, and leaves that account-level state alone. + * Why this is separate from the account-level settlement resource. {@code ShareAdvanceCollectionApiResource} + * settles an account's first application. It values the settlement by summing every purchase row on the + * account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a + * second tranche: the valuation would charge the member for their entire holding history. So tranche funding is + * valued and recorded per transaction, and leaves that account-level state alone. * *

* Where the money ends up. Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 22d3189e7..cde09257e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -268,9 +268,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA // shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share // Capital at this point issues share capital against a Sundry balance that holds no // receipt for it — in effect funding one member's shares out of money other members - // paid in. The entries belong to settlement, where the money actually arrives: either - // applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting - // their savings (ShareSavingsDebitRequestApiResource). + // paid in. The entries belong to settlement, where the money actually arrives: applying + // the member's cash receipt (ShareAdvanceCollectionApiResource). businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); return new CommandProcessingResultBuilder() // @@ -367,15 +366,14 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA // Charges are deliberately NOT marked paid here. Attaching an entry fee when the // account is created records what the member owes; activating the account decides they - // may hold shares. Neither collects anything, and nobody has yet said where the money - // is coming from — a cash receipt at the counter, or a debit from the member's savings. + // may hold shares. Neither collects anything, and nobody has yet said the cash receipt + // at the counter has actually been taken. // // Marking it paid at activation left the fee showing as settled while it was still // outstanding, so the settlement step then asked the member for the share value alone // and the fee was never collected at all. It is marked paid where the money actually - // arrives: ShareAdvanceCollectionApiResource (cash receipt) or - // ShareSavingsDebitRequestApiResource (savings debit), both of which set - // amount_paid_derived and amount_outstanding_derived alongside the flag. + // arrives: ShareAdvanceCollectionApiResource, which sets amount_paid_derived and + // amount_outstanding_derived alongside the flag. return new CommandProcessingResultBuilder() // .withCommandId(jsonCommand.commandId()) // diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index e4b93b87f..a226f501d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -46,4 +46,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml new file mode 100644 index 000000000..aae3679c7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + From 509370eaeb623462be6ec0ed918401faae1a2975 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 31 Aug 2026 15:06:57 +0530 Subject: [PATCH 22/55] fix(shares,voucher,cashreport): route redeem/withdrawal through Sundry, fix voucher cashier attribution and GL-name resolution - ClerkShareWithdrawalRequestApiResource: reverse native redeem GL legs and reroute through Sundry (Dr Share Reference / Cr Sundry, then Dr Sundry / Cr Savings Control), reusing the native transaction_id so both legs display as balanced; wholesale-reverse and reroute the linked savings credit the same way. - ShareAccountWritePlatformServiceJpaRepositoryImpl: undoApproveShareAccount no longer re-triggers the native "isApplied()" phantom GL posting Fineract's accounting bridge makes on approval. - VoucherService/ClerkWithdrawalRequestApiResource: attribute a voucher to the cashier who actually disbursed cash, not the submitting clerk, once disbursal completes. - CashReportApiResource: scope the GL-entries deposit/withdrawal joins by txn_type to stop a hex/decimal txn_id collision from borrowing an unrelated transaction's voucher number; resolve GL account names (not just client names) in the "A/C NAME" column. fix(branchday,cashreport): stop the vault position from double-counting a cashier's unsettled cash BranchDayRegisterApiResource's day-close folded every cashier's still-held cash into the branch's combined closing figure, which then carried forward as next day's vault opening. When that cash was later settled back to the vault, CashReportApiResource added it a second time - once already baked into the carried-forward opening, once as the settlement itself - manufacturing a phantom "Excess" equal to whatever was unsettled. Adds a vault-only OPENING_VAULT/CLOSING_VAULT leg (new VaultPositionService, shared by both resources) alongside the existing branch-total OPENING/CLOSING legs, so the vault's own anchor never includes cash that still physically sits with a cashier. feat(accounting): seed a missing opening-balance contra account and a real bank's chart of accounts - Financial Activity 300 (opening-balance transfer contra) was never mapped in any tenant's chart of accounts, so any opening-balance flow failed outright; backfilled it and added a migration to seed it for every tenant going forward. - Seeds a brand-new, otherwise-empty tenant with a real cooperative bank's chart of accounts (transcribed from its balance sheet), guarded to never touch a tenant that already has its own. - Removes 0451's hardcoded demo branch offices, which were seeding into every new tenant regardless of institution. Co-Authored-By: Claude Sonnet 5 --- .../api/BranchDayRegisterApiResource.java | 77 +++++- .../cashreport/api/CashReportApiResource.java | 129 ++-------- .../service/VaultPositionService.java | 169 +++++++++++++ ...lerkShareWithdrawalRequestApiResource.java | 202 ++++++++++++++- .../ClerkWithdrawalRequestApiResource.java | 5 + ...WritePlatformServiceJpaRepositoryImpl.java | 12 +- .../voucher/service/VoucherService.java | 15 ++ .../db/changelog/tenant/changelog-tenant.xml | 1 - .../tenant/final-changelog-tenant.xml | 3 + .../tenant/parts/0451_seed_branch_offices.xml | 59 ----- ..._branch_day_register_vault_only_detail.xml | 38 +++ ...22_seed_opening_balance_contra_account.xml | 36 +++ ...523_seed_basoda_bank_chart_of_accounts.xml | 237 ++++++++++++++++++ 13 files changed, 797 insertions(+), 186 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java delete mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_branch_day_register_vault_only_detail.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_seed_opening_balance_contra_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_seed_basoda_bank_chart_of_accounts.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 3fc675902..cd36ae858 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityEx import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -64,6 +65,7 @@ public class BranchDayRegisterApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -271,6 +273,7 @@ public class BranchDayRegisterApiResource { Timestamp.from(Instant.now()), carried, opensWithADifference ? openingDifferenceReason.trim() : null); saveDenominationDetail(id, "OPENING", effectiveDenominations); + carryForwardVaultDenominations(officeId, id, businessDate); final Map res = new LinkedHashMap<>(); res.put("resourceId", id); @@ -342,6 +345,7 @@ public class BranchDayRegisterApiResource { final Long registerId = ((Number) found.get(0).get("id")).longValue(); saveDenominationDetail(registerId, "CLOSING", denominations); + snapshotVaultDenominations(officeId, registerId, businessDate); final Map res = new LinkedHashMap<>(); res.put("officeId", officeId); @@ -448,6 +452,16 @@ public class BranchDayRegisterApiResource { } private void saveDenominationDetail(final Long registerId, final String leg, final List> denominations) { + saveDenominationDetail(registerId, leg, null, denominations); + } + + /** + * {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier + * added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see + * {@link VaultPositionService} for why the two are never allowed to mix. + */ + private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId, + final List> denominations) { if (denominations == null || denominations.isEmpty()) { return; } @@ -456,10 +470,67 @@ public class BranchDayRegisterApiResource { final int count = (int) row.get("count"); jdbcTemplate.update(""" INSERT INTO m_branch_day_register_detail - (register_id, leg, denomination_id, denomination_value, count, total_amount) - VALUES (?,?,?,?,?,?) + (register_id, leg, denomination_id, denomination_value, count, total_amount, teller_id) + VALUES (?,?,?,?,?,?,?) """, registerId, leg, row.get("denominationId"), value, count, - BigDecimal.valueOf((long) value * count)); + BigDecimal.valueOf((long) value * count), tellerId); + } + } + + /** Copies one register's vault-only CLOSING_VAULT rows into another register's OPENING_VAULT, + * per teller — the vault-scoped analogue of {@link #lastClosingDenominations}. */ + private void carryForwardVaultDenominations(final Long officeId, final Long newRegisterId, final LocalDate businessDate) { + final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ?", Long.class, + officeId); + for (final Long tellerId : tellerIds) { + final List priorRegisterIds = jdbcTemplate.queryForList(""" + SELECT r.id FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date < ? + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'CLOSING_VAULT' AND d.teller_id = ?) + ORDER BY r.business_date DESC LIMIT 1 + """, Long.class, officeId, Date.valueOf(businessDate), tellerId); + if (priorRegisterIds.isEmpty()) { + continue; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT denomination_id AS "denominationId", denomination_value AS "denominationValue", count + FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = 'CLOSING_VAULT' AND teller_id = ? + """, priorRegisterIds.get(0), tellerId); + saveDenominationDetail(newRegisterId, "OPENING_VAULT", tellerId, rows); + } + } + + /** Snapshots what each teller under this office is holding right now, as CLOSING_VAULT — the + * input {@link #carryForwardVaultDenominations} carries into tomorrow's OPENING_VAULT. Read + * from {@link VaultPositionService} rather than from whatever was physically counted at close, + * so the vault-only chain stays self-consistent even when a closer's manual count differs from + * what the books show — that difference is a book-vs-physical question, not a vault-vs-cashier + * split question. */ + private void snapshotVaultDenominations(final Long officeId, final Long registerId, final LocalDate businessDate) { + final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ?", Long.class, + officeId); + for (final Long tellerId : tellerIds) { + final Map position = vaultPositionService.buildPositionForVault(tellerId, businessDate.toString()); + if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) { + continue; + } + @SuppressWarnings("unchecked") + final List> denoms = (List>) position.get("denominations"); + final List> rows = new ArrayList<>(); + for (final Map d : denoms) { + final int count = ((Number) d.get("count")).intValue(); + if (count <= 0) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("denominationId", d.get("denominationId")); + row.put("denominationValue", ((Number) d.get("denominationValue")).intValue()); + row.put("count", count); + rows.add(row); + } + saveDenominationDetail(registerId, "CLOSING_VAULT", tellerId, rows); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index aa73ec92e..794cf15c6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -15,6 +15,7 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -30,6 +31,7 @@ public class CashReportApiResource { private final PlatformSecurityContext context; private final DenominationBreakdownWriter denominationBreakdownWriter; private final CashierRoleHelper cashierRoleHelper; + private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); /** @@ -542,11 +544,7 @@ public class CashReportApiResource { * GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd * Denomination-wise stock a vault (teller) is currently holding — "how much of what does the * bank have", as opposed to cashier-position which answers the same question for a cashier. - * Anchored on the most recent business day (on or before asOfDate) whose branch day register - * recorded an OPENING denomination breakdown, then walked forward by everything settled back - * into this vault and handed out of it since. A vault whose opening days never recorded a - * breakdown (plain lump-sum opens only) has nothing to report per denomination — the response - * says so explicitly rather than silently returning zeros that look like an empty vault. + * See {@link VaultPositionService} for how the anchor is chosen. */ @GET @Path("vault-position") @@ -559,107 +557,11 @@ public class CashReportApiResource { final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); final List> all = new ArrayList<>(); for (final Long id : tellerIds) { - all.add(buildPositionForVault(id, dateFilter)); + all.add(vaultPositionService.buildPositionForVault(id, dateFilter)); } return gson.toJson(all); } - return gson.toJson(buildPositionForVault(tellerId, dateFilter)); - } - - private Map buildPositionForVault(final Long tellerId, final String dateFilter) { - final Map result = new LinkedHashMap<>(); - result.put("tellerId", tellerId); - result.put("asOfDate", dateFilter); - - String tellerName = null; - Long officeId = null; - try { - final Map teller = jdbcTemplate.queryForMap( - "SELECT name, office_id FROM m_tellers WHERE id = ?", tellerId); - tellerName = (String) teller.get("name"); - officeId = ((Number) teller.get("office_id")).longValue(); - } catch (Exception ignored) { - // intentionally ignored - } - result.put("tellerName", tellerName); - - if (officeId == null) { - result.put("hasBreakdown", false); - result.put("totalAmount", BigDecimal.ZERO); - result.put("denominations", new ArrayList<>()); - return result; - } - - // The anchor: the most recent register on or before asOfDate that actually recorded an - // opening breakdown. Its breakdown already reflects every prior day's carry-forward (see - // BranchDayRegisterApiResource#lastClosingDenominations), so only movements from that one - // anchor date forward need to be added on top of it. - final List> anchorRows = jdbcTemplate.queryForList(""" - SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r - WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) - AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d - WHERE d.register_id = r.id AND d.leg = 'OPENING') - ORDER BY r.business_date DESC LIMIT 1 - """, officeId, dateFilter); - - if (anchorRows.isEmpty()) { - result.put("hasBreakdown", false); - result.put("totalAmount", BigDecimal.ZERO); - result.put("denominations", new ArrayList<>()); - return result; - } - - final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue(); - final String anchorDate = anchorRows.get(0).get("business_date").toString(); - - final Map balance = new LinkedHashMap<>(); - - addToBalance(balance, jdbcTemplate.query(""" - SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count - FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id - WHERE d.register_id = ? AND d.leg = 'OPENING' - """, DENOM_ROW, registerId), 1); - - addToBalance(balance, jdbcTemplate.query(""" - SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count - FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id - LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id - WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) - AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE) - GROUP BY csd.denomination_id, csd.denomination_value, dm.kind - """, DENOM_ROW, tellerId, anchorDate, dateFilter), 1); - - addToBalance(balance, jdbcTemplate.query(""" - SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count - FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id - LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id - WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) - AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE) - GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind - """, DENOM_ROW, tellerId, anchorDate, dateFilter), -1); - - final List> rows = new ArrayList<>(); - balance.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) - .forEach(e -> { - final Map row = new LinkedHashMap<>(); - row.put("denominationId", e.getKey()); - row.put("denominationValue", (int) e.getValue()[0]); - row.put("kind", e.getValue()[1] == 1L ? "coin" : "note"); - row.put("count", e.getValue()[2]); - row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2])); - rows.add(row); - }); - - final BigDecimal totalAmount = rows.stream() - .map(r -> (BigDecimal) r.get("amount")) - .reduce(BigDecimal.ZERO, BigDecimal::add); - - result.put("hasBreakdown", true); - result.put("anchorDate", anchorDate); - result.put("totalAmount", totalAmount); - result.put("denominations", rows); - return result; + return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter)); } /** @@ -914,12 +816,23 @@ public class CashReportApiResource { // contains a-f, is never sent through the hex cast at all). final String baseGroupSql = "FROM m_savings_txn_denomination std " + "LEFT JOIN m_client mc ON mc.id = std.client_id " - + "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + // A GL-type row (no client_id) carries the GL account's own code in account_no + // rather than a savings/current account number, so its "name" has to come from the + // chart of accounts, not from a client that was never party to the transaction. + + "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no " + // Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's + // decimal txn_id can coincidentally hex-decode to the exact same number (confirmed + // live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to + // 178810369551311), and an unscoped join matched the withdrawal row to that unrelated + // deposit's voucher, which COALESCE then preferred over the withdrawal's own real one. + + "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND " + + " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' " - + " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " - + "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + + " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) " + + "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND " + + " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' " - + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " + + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; @@ -942,7 +855,7 @@ public class CashReportApiResource { final String dataSql = "SELECT " + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " std.account_no AS ac_no, " - + " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, " + + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " MAX(std.txn_date) AS txn_date, " + " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, " + " MAX(std.officer_username) AS teller_name, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java new file mode 100644 index 000000000..1e7510f31 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java @@ -0,0 +1,169 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.cashreport.service; + +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.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +/** + * Denomination-wise cash a vault (teller) is currently holding. + * + * Anchored on the most recent business day (on or before the target date) whose branch day + * register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact + * teller), then walked forward by everything settled into this vault and handed out of it since. + * Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows + * (which mix the vault with whatever every cashier under it is holding) precisely so this anchor + * never includes cash that still physically sits with a cashier — mixing the two double-counts + * whatever a cashier settles back later, once already folded into the branch total, again as the + * settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT} + * row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor + * still returns something rather than zero. + */ +@Component +@RequiredArgsConstructor +public class VaultPositionService { + + private final JdbcTemplate jdbcTemplate; + + // Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count} + private static final RowMapper DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"), + rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") }; + + public Map buildPositionForVault(final Long tellerId, final String dateFilter) { + final Map result = new LinkedHashMap<>(); + result.put("tellerId", tellerId); + result.put("asOfDate", dateFilter); + + String tellerName = null; + Long officeId = null; + try { + final Map teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?", + tellerId); + tellerName = (String) teller.get("name"); + officeId = ((Number) teller.get("office_id")).longValue(); + } catch (final Exception ignored) { + // intentionally ignored + } + result.put("tellerName", tellerName); + + if (officeId == null) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + // Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried + // forward day to day, with no cashier's held cash folded in. Fall back to the old + // branch-combined OPENING leg only when this office has no vault-scoped row at all on or + // before the target date (a day closed before vault-only tracking existed). + List> anchorRows = jdbcTemplate.queryForList(""" + SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?) + ORDER BY r.business_date DESC LIMIT 1 + """, officeId, dateFilter, tellerId); + String anchorLeg = "OPENING_VAULT"; + if (anchorRows.isEmpty()) { + anchorLeg = "OPENING"; + anchorRows = jdbcTemplate.queryForList(""" + SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'OPENING') + ORDER BY r.business_date DESC LIMIT 1 + """, officeId, dateFilter); + } + + if (anchorRows.isEmpty()) { + result.put("hasBreakdown", false); + result.put("totalAmount", BigDecimal.ZERO); + result.put("denominations", new ArrayList<>()); + return result; + } + + final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue(); + final String anchorDate = anchorRows.get(0).get("business_date").toString(); + + final Map balance = new LinkedHashMap<>(); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count + FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id + WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?) + """, DENOM_ROW, registerId, anchorLeg, tellerId), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count + FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id + LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id + WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) + AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE) + GROUP BY csd.denomination_id, csd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), 1); + + addToBalance(balance, jdbcTemplate.query(""" + SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count + FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id + LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id + WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) + AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE) + GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind + """, DENOM_ROW, tellerId, anchorDate, dateFilter), -1); + + final List> rows = new ArrayList<>(); + balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> { + final Map row = new LinkedHashMap<>(); + row.put("denominationId", e.getKey()); + row.put("denominationValue", (int) e.getValue()[0]); + row.put("kind", e.getValue()[1] == 1L ? "coin" : "note"); + row.put("count", e.getValue()[2]); + row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2])); + rows.add(row); + }); + + final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, + BigDecimal::add); + + result.put("hasBreakdown", true); + result.put("anchorDate", anchorDate); + result.put("totalAmount", totalAmount); + result.put("denominations", rows); + return result; + } + + private void addToBalance(final Map balance, final List rows, final int sign) { + for (final Object[] row : rows) { + final Long denomId = (Long) row[0]; + final int denomValue = (Integer) row[1]; + final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L; + final long count = (Long) row[3]; + final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L }); + entry[2] += count * sign; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 6301a771a..2b3cf5a23 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -41,6 +41,8 @@ import java.sql.Date; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -55,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -97,6 +100,20 @@ public class ClerkShareWithdrawalRequestApiResource { // instead of silently posting its settlement with none at all. private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER"; + // ── Sundry pass-through for the redemption settlement ─────────────────── + // Fineract convention: 1 = CREDIT, 2 = DEBIT. + private static final int CREDIT = 1; + private static final int DEBIT = 2; + + // acc_product_mapping.financial_account_type for share products. + private static final int FA_SHARES_REFERENCE = 1; + private static final int FA_SHARES_SUSPENSE = 2; + private static final int PRODUCT_TYPE_SHARES = 4; + + // acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. + private static final int PRODUCT_TYPE_SAVINGS = 2; + private static final int FA_SAVINGS_CONTROL = 2; + // ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ─── @POST @@ -629,11 +646,20 @@ public class ClerkShareWithdrawalRequestApiResource { final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null; final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null; + // Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into + // Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or + // leaves share capital in this codebase passes through Sundry, and leaving redeem on its own + // "give cash back over the counter" assumption breaks that the moment the settlement below + // lands on a different GL than this leg did. + if (shareTransactionId != null) { + routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)), + currentUser.getId()); + } + // Settlement: within this SAME approval transaction, immediately credit the redemption value - // to the member's linked savings account. Two Fineract journal entries get posted — native - // redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference / - // Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined - // financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry. + // to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly, + // not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally + // land on the same account the leg above credited. See creditLinkedSavingsAccount(). final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id"); final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number ? ((Number) storedSettlementPaymentTypeId).longValue() @@ -715,14 +741,17 @@ public class ClerkShareWithdrawalRequestApiResource { * Reads the amount straight off the just-created redeem transaction row rather than re-deriving * the unit price ourselves — the transaction was already priced by Fineract's own * {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily - * today) and already nets out any redemption charge. Uses Fineract's own native savings deposit - * command so the transaction is fully audited and posts its own GL entry the normal way — the - * debit side lands on whichever GL account is mapped to the settlement payment type the clerk - * chose at submit time (validated to be non-cash and persisted on the request row) — matching the - * Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero - * on that bridge account. Falls back to the legacy hardcoded - * {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this - * column existed. + * today) and already nets out any redemption charge. + * + *

+ * Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the + * member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry + * (Dr whichever GL the settlement payment type's Fund Source maps to / Cr Savings Control) is then + * reversed in full and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used + * to be left to the payment type's own mapping in the hope it would land on the same account + * {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it + * did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually + * met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental. */ private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr, final String resolutionNumber, final Long settlementPaymentTypeId) { @@ -761,7 +790,35 @@ public class ClerkShareWithdrawalRequestApiResource { final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId) .withJson(payload.toString()).build(); final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - return new Settlement(settlementAmount, result.getResourceId()); + final Long savingsTransactionId = result.getResourceId(); + + // The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather + // than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands + // on its own regardless of which GL the payment type's Fund Source mapping happened to hit. + jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId); + + final Map acct = loadShareAccountGlContext(shareAccountId); + final Long productId = toLong(acct.get("product_id")); + final Long officeId = toLong(acct.get("office_id")); + final String currencyCode = (String) acct.get("currency_code"); + final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw apiError(409, "The Savings Control GL is not mapped for this savings account's product."); + } + + final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10)); + final long userId = context.authenticatedUser().getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHRDMSTL-" + System.nanoTime(); + final String accountNo = (String) acct.get("account_no"); + + insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share redemption settled to savings | " + accountNo); + insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share redemption settlement credited to savings | " + accountNo); + + return new Settlement(settlementAmount, savingsTransactionId); } // ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─ @@ -832,6 +889,125 @@ public class ClerkShareWithdrawalRequestApiResource { } } + // ── Sundry pass-through helpers ────────────────────────────────────────── + + /** + * Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry. + * + *

+ * Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr + * Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the + * member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement + * posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited + * for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference" + * GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be + * left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry + * the same way every other share-capital movement in this codebase does. + */ + private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate, + final long userId) { + final Map acct = loadShareAccountGlContext(shareAccountId); + final Long productId = toLong(acct.get("product_id")); + final Long officeId = toLong(acct.get("office_id")); + final String currencyCode = (String) acct.get("currency_code"); + final String accountNo = (String) acct.get("account_no"); + + final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"); + final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + + final Map reversedLeg = jdbcTemplate + .queryForList( + "SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? " + + " AND type_enum = ? AND reversed = false", + shareTransactionId, glReference, CREDIT) + .stream().findFirst().orElse(null); + if (reversedLeg == null) { + // Nothing to redirect -- the native posting didn't credit Shares Reference at all (an + // unexpected accounting configuration), so there is no leg here to reroute. + return; + } + final BigDecimal amount = (BigDecimal) reversedLeg.get("amount"); + // Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are + // grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr + // Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone, + // instead of the two reading as the one balanced entry they actually are. + final String nativeTxnId = (String) reversedLeg.get("transaction_id"); + + jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? " + + " AND type_enum = ? AND reversed = false", + shareTransactionId, glReference, CREDIT); + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share redemption held in Sundry pending settlement | " + accountNo); + } + + private Map loadShareAccountGlContext(final Long shareAccountId) { + return jdbcTemplate.queryForMap(""" + SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id + FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id + WHERE sa.id = ? + """, shareAccountId); + } + + private Long requireShareGl(final Long productId, final int financialAccountType, final String label) { + final Long glId = resolveGl(productId, financialAccountType); + if (glId == null) { + throw apiError(409, label + " GL is not mapped for share product " + productId + "."); + } + return glId; + } + + private Long resolveGl(final Long productId, final int financialAccountType) { + try { + return jdbcTemplate.queryForObject( + "SELECT gl_account_id FROM acc_product_mapping " + + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", + Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); + } catch (final IncorrectResultSizeDataAccessException e) { + return null; + } + } + + private Long savingsControlGlOf(final Long savingsAccountId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT apm.gl_account_id FROM m_savings_account sa + JOIN acc_product_mapping apm ON apm.product_id = sa.product_id + WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 + """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); + return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); + } + + private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, + final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, + final String description) { + jdbcTemplate.update( + "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + + " entry_date, type_enum, amount, description, entity_id, " + + " created_by, last_modified_by, created_date, lastmodified_date, " + + " is_running_balance_calculated, office_running_balance, " + + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, + userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + } + + /** + * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. + * + * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back + * as 00:04 the next day. The instant has to be resolved in the tenant's own zone first, which is the zone + * {@code now} was taken in. + */ + private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { + return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC); + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { JsonArray log = new JsonArray(); if (existingJson != null && !existingJson.isBlank()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 8a5985e8b..03dcd8d24 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -605,6 +605,11 @@ public class ClerkWithdrawalRequestApiResource { throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented."); } + // The voucher was raised in the clerk's name at submission, before any cashier was involved -- + // reattribute it to whoever is actually handing over the cash now, so it shows up in their own + // voucher register rather than only in the clerk's. + voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser); + // Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates if (denominationData != null && !denominationData.isBlank()) { saveDisbursementDenomination(denominationData, record, currentUser); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index cde09257e..37410ae78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -340,9 +340,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA } } LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + // Reverts whatever approveShareAccount() posted -- which, per the comment there, is + // nothing. Kept anyway as a no-op safety net for any transaction that predates that fix. this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); + // Deliberately NOT re-running the accounting bridge for the transactions this undo just + // put back to APPLIED. That bridge's "isApplied" branch posts Dr Shares Reference (Cash at + // Vault) / Cr Share Sundry, assuming cash was already received at the counter for these + // shares -- exactly the phantom entry approveShareAccount()/createShareAccount() were + // fixed to stop posting. Calling it here reintroduced the same phantom entry by a side + // door: undo-approve reverting a transaction to APPLIED status looks, to the bridge, just + // like a brand-new application being submitted. Money only ever reaches or leaves share + // capital through ShareAdvanceCollectionApiResource's own explicit Sundry-routed postings. return new CommandProcessingResultBuilder() // .withCommandId(jsonCommand.commandId()) // .withEntityId(accountId) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index 9190879ce..e56d5981f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -467,4 +467,19 @@ public class VoucherService { "UPDATE m_voucher SET amount = ? WHERE reference_type = ? AND reference_id = ?", amount, referenceType, referenceId); } + + /** + * Reattributes a voucher to the cashier who actually disbursed the cash, once that is known. + * + * A clerk withdrawal is raised by the clerk who takes the request, so that is who + * {@link #createVoucher} stamps as the creator at submission time -- but no cash has moved yet, + * and it is a cashier, at their own counter, who later hands it over. A voucher names whoever's + * till the money actually came out of, so the per-user "your own vouchers" register a cashier + * sees is complete rather than silently missing every withdrawal they personally paid out. + */ + public void updateCashier(final String referenceType, final Long referenceId, final AppUser cashier) { + jdbcTemplate.update( + "UPDATE m_voucher SET created_by = ?, created_by_username = ? WHERE reference_type = ? AND reference_id = ?", + cashier.getId(), cashier.getUsername(), referenceType, referenceId); + } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 66e5c3fee..4b383b1b2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -502,7 +502,6 @@ - diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index a226f501d..09a09f1a0 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -47,4 +47,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml deleted file mode 100644 index 577dcd871..000000000 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0451_seed_branch_offices.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - SELECT COUNT(*) FROM m_office WHERE external_id IN ('0002','0003','0004','0005','0006') - - - INSERT INTO m_office (parent_id, hierarchy, external_id, name, opening_date) - VALUES - ((SELECT id FROM m_office WHERE hierarchy = '.'), '.2.', '0002', 'Andheri (E)', DATE '2018-04-01'), - ((SELECT id FROM m_office WHERE hierarchy = '.'), '.3.', '0003', 'Bandra (W)', DATE '2018-04-01'), - ((SELECT id FROM m_office WHERE hierarchy = '.'), '.4.', '0004', 'Borivali', DATE '2019-07-01'), - ((SELECT id FROM m_office WHERE hierarchy = '.'), '.5.', '0005', 'Dadar', DATE '2020-01-02'), - ((SELECT id FROM m_office WHERE hierarchy = '.'), '.6.', '0006', 'Thane', DATE '2021-06-01'); - - - - - - - SELECT COUNT(*) FROM m_office WHERE external_id = 'HO' - - - UPDATE m_office SET external_id = 'HO' WHERE hierarchy = '.'; - - - - diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_branch_day_register_vault_only_detail.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_branch_day_register_vault_only_detail.xml new file mode 100644 index 000000000..51436a6a6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_branch_day_register_vault_only_detail.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_seed_opening_balance_contra_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_seed_opening_balance_contra_account.xml new file mode 100644 index 000000000..660f7f003 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_seed_opening_balance_contra_account.xml @@ -0,0 +1,36 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '300000' + + + INSERT INTO acc_gl_account + (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) + VALUES ('Opening Balances Transfer Contra', '300000', false, true, 1, 3); + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 300 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + VALUES (300, (SELECT id FROM acc_gl_account WHERE gl_code = '300000')); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_seed_basoda_bank_chart_of_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_seed_basoda_bank_chart_of_accounts.xml new file mode 100644 index 000000000..03e022fe6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_seed_basoda_bank_chart_of_accounts.xml @@ -0,0 +1,237 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code != '300000' + + + + + + + From dc0539d53d0e9e317cebc29a42cb0fddc1d5ed03 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 31 Aug 2026 22:17:54 +0530 Subject: [PATCH 23/55] feat(savings,shares): bidirectional savings/share account linking, optional savings link on share create - Savings account creation validates and stores a required "shareAccountId" once Share Linking is enabled, checked against the client's own active share accounts (raw SQL - fineract-savings has no dependency on the shareaccounts domain). On save, mirrors the link back onto the share account's own savings_account_id, the same way that field already gets set from the share side. - New GET /clients/{id}/activeshareaccounts - a client's active share accounts, for the savings creation screen's picker (kept separate from the shared Savings/FD/RD/Current template DTO). - ShareAccountDataSerializer.validateAndCreate no longer requires savingsAccountId on create - the DB column and domain relationship were already nullable, only this validation blocked it. fix(branchday): stop the denomination carry-forward preview from doubling yesterday's closing count GET /branchday/denominations classified every detail row that wasn't literally leg='OPENING' as "closing" - which, once CLOSING_VAULT rows existed alongside CLOSING (single-teller offices record the same breakdown under both), silently doubled every count shown as "yesterday's closing" when opening the next business day. Filters to leg IN ('OPENING','CLOSING') explicitly instead of an else-catches-everything branch. feat(accounting): seed missing Income/Expense/Transfers-Suspense/Escheat-Liability accounts The Basoda-derived chart of accounts (0523) carried none of these - a balance sheet has no P&L heads, and Transfers Suspense/Escheat Liability aren't balance-sheet line items either - so a Savings Product's accounting mapping had nowhere to point those required fields. Same guard as 0523: only seeds a Basoda-derived tenant that hasn't already gotten it, never touches a tenant with its own chart of accounts. Co-Authored-By: Claude Sonnet 5 --- .../savings/SavingsApiConstants.java | 1 + .../api/BranchDayRegisterApiResource.java | 217 +++++++++++++++++- .../domain/SavingsAccountAssembler.java | 24 ++ ...WritePlatformServiceJpaRepositoryImpl.java | 11 + .../savings/starter/SavingsConfiguration.java | 4 +- .../ClientActiveShareAccountsApiResource.java | 67 ++++++ .../shareaccounts/domain/ShareAccount.java | 2 +- .../ShareAccountDataSerializer.java | 19 +- .../tenant/final-changelog-tenant.xml | 1 + ...ed_basoda_bank_income_expense_accounts.xml | 45 ++++ .../savings/data/SavingsAccountConstant.java | 3 +- .../savings/domain/SavingsAccount.java | 13 ++ .../savings/parts/module-changelog-master.xml | 1 + ...dd_share_account_id_to_savings_account.xml | 28 +++ 14 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ClientActiveShareAccountsApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_seed_basoda_bank_income_expense_accounts.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_add_share_account_id_to_savings_account.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index 8197a2477..1bde1ad04 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -229,6 +229,7 @@ public class SavingsApiConstants { public static final String endDateParamName = "endDate"; public static final String introducerRequiredParamName = "introducerRequired"; public static final String shareLinkingEnabledParamName = "shareLinkingEnabled"; + public static final String shareAccountIdParamName = "shareAccountId"; public static final String nominationFacilityParamName = "nominationFacility"; public static final String dormancyAlertParamName = "dormancyAlert"; public static final String interestCalculateSelectorParamName = "interestCalculateSelector"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index cd36ae858..ab0a6d421 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -212,6 +212,24 @@ public class BranchDayRegisterApiResource { final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); + final Map res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations, + openingDifferenceReason); + return gson.toJson(res); + } + + /** + * The actual open-day work, factored out of {@link #openDay(String)} so + * {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it + * has already reconciled against the cash GL account, without round-tripping through JSON. + */ + private Map openDayInternal(final AppUser currentUser, final Long officeId, final LocalDate businessDate, + final BigDecimal openingCash, final List> denominations, final String openingDifferenceReason) { + // A branch with no vault yet (a freshly set-up institution, before anyone has visited Manage + // Tellers) cannot record a denomination breakdown at all — vault-position tracking is + // per-teller. Rather than blocking the very first day open on a separate setup step, open a + // day self-provisions the office's vault the same way its GL was self-provisioned. + ensureTellerExists(officeId, businessDate); + // A branch runs one day at a time. Opening a second while the first is still open is how a // day gets stranded, so the previous one has to be closed first. final Map open = findOpenDay(officeId); @@ -281,9 +299,164 @@ public class BranchDayRegisterApiResource { res.put("businessDate", businessDate.toString()); res.put("openingCash", carried == null ? BigDecimal.ZERO : carried); res.put("status", "OPEN"); + return res; + } + + // ── POST /v1/branchday/migrate-cash-denomination ──────────────────────────── + + /** + * GET /v1/branchday/cash-gl-status?officeId=X + * + * What the office's cash-at-vault GL account currently holds, so the migration screen can show + * "put this much into the drawer" before anyone starts counting, and so the frontend knows + * whether this is a first-time setup (no balance posted yet, the count itself will define it) or + * a top-up (a balance already exists, the count must match it exactly). + */ + @GET + @Path("cash-gl-status") + public String cashGlStatus(@QueryParam("officeId") final Long officeIdParam) { + final AppUser currentUser = this.context.authenticatedUser(); + final Long officeId = resolveOffice(officeIdParam, currentUser); + + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + try { + final Map gl = resolveCashGlAccount(); + final Long glAccountId = ((Number) gl.get("id")).longValue(); + final BigDecimal currentBalance = currentGlBalance(glAccountId, officeId); + result.put("glAccountId", glAccountId); + result.put("glAccountName", gl.get("name")); + result.put("glCode", gl.get("gl_code")); + result.put("currentBalance", currentBalance); + result.put("hasBalance", currentBalance.compareTo(BigDecimal.ZERO) != 0); + } catch (final PlatformDataIntegrityException e) { + result.put("glAccountId", null); + result.put("configured", false); + result.put("message", e.getDefaultUserMessage()); + } + return gson.toJson(result); + } + + /** + * POST /v1/branchday/migrate-cash-denomination + * + * The one-step "new institution" path from a GL that was just seeded (Migrate Opening Balances, + * or nothing at all yet) straight to a countable vault: count the drawer, and this either + * (a) defines the cash GL's opening balance FROM that count, when nothing has been posted to it + * yet, or (b) requires the count to match what was already posted, when it has - the same + * "physical count is the source of truth, but it still has to reconcile" rule the ordinary + * Open Business Day screen applies against yesterday's closing figure. Either way, it then opens + * the business day with that same breakdown, so this single action is the whole "day 1" story. + */ + @POST + @Path("migrate-cash-denomination") + @Consumes(MediaType.APPLICATION_JSON) + public String migrateCashDenomination(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + requireDayAuthority(currentUser); + + final var root = this.fromJsonHelper.parse(body); + final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); + final LocalDate businessDate = extractDate(root, "businessDate", + LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + final List> denominations = extractDenominations(root, "denominations"); + final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); + + final BigDecimal denominationTotal = sumDenominations(denominations); + if (denominationTotal == null || denominationTotal.compareTo(BigDecimal.ZERO) <= 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.denomination.required", + "Count at least one denomination before migrating."); + } + + final Map gl = resolveCashGlAccount(); + final Long glAccountId = ((Number) gl.get("id")).longValue(); + final BigDecimal currentBalance = currentGlBalance(glAccountId, officeId); + final boolean glAlreadyHasABalance = currentBalance.compareTo(BigDecimal.ZERO) != 0; + + if (glAlreadyHasABalance) { + if (denominationTotal.compareTo(currentBalance) != 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.cash.gl.mismatch", + "The cash GL account (" + gl.get("name") + ") already carries Rs. " + currentBalance + + " — the counted denomination total (Rs. " + denominationTotal + + ") must match it exactly."); + } + } else { + postCashOpeningBalance(officeId, businessDate, glAccountId, denominationTotal, currentUser); + } + + final Map res = openDayInternal(currentUser, officeId, businessDate, null, denominations, + openingDifferenceReason); + res.put("glAccountId", glAccountId); + res.put("glAccountName", gl.get("name")); + res.put("glOpeningBalancePosted", !glAlreadyHasABalance); return gson.toJson(res); } + /** The GL account this tenant's cash-at-vault / cash-at-teller financial activities are mapped + * to — the one account this office's counted cash is supposed to agree with. */ + private Map resolveCashGlAccount() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT g.id, g.name, g.gl_code FROM acc_gl_financial_activity_account f + JOIN acc_gl_account g ON g.id = f.gl_account_id + WHERE f.financial_activity_type IN (101, 102) + ORDER BY f.financial_activity_type LIMIT 1 + """); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.cash.account.not.configured", + "No Cash at Vault / Cash at Teller account is configured for this tenant yet. " + + "Map Financial Activity 101 (or 102) to a GL account first."); + } + return rows.get(0); + } + + /** Net Dr-minus-Cr posted to this GL account for this office so far — zero means nothing has + * ever been posted to it, the ordinary "brand new ledger" state. */ + private BigDecimal currentGlBalance(final Long glAccountId, final Long officeId) { + final BigDecimal net = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END), 0) + FROM acc_gl_journal_entry + WHERE account_id = ? AND office_id = ? AND reversed = false + """, BigDecimal.class, glAccountId, officeId); + return net == null ? BigDecimal.ZERO : net; + } + + /** Posts a single-account opening balance: Dr the cash GL, Cr the Opening Balances Transfer + * Contra account (financial activity 300) - the same contra leg Fineract's own "Define Opening + * Balances" journal entry uses, just scoped to the one account this screen is responsible for + * instead of the whole chart of accounts. */ + private void postCashOpeningBalance(final Long officeId, final LocalDate businessDate, final Long cashGlAccountId, + final BigDecimal amount, final AppUser currentUser) { + final List contraIds = jdbcTemplate.queryForList(""" + SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = 300 + """, Long.class); + if (contraIds.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.opening.balance.contra.not.configured", + "The Opening Balances Transfer Contra account (Financial Activity 300) is not configured for this tenant."); + } + final Long contraId = contraIds.get(0); + final String currencyCode = jdbcTemplate.queryForObject( + "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); + final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-" + + System.nanoTime(); + + insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser); + insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser); + } + + /** type_enum: 2 = DEBIT, 1 = CREDIT (matches org.apache.fineract.accounting.journalentry.domain.JournalEntryType). */ + private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, + final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, + final AppUser currentUser) { + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, + amount, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, + last_modified_on_utc, submitted_on_date, transaction_date) + VALUES (?,?,?,?,false,true,?,?,?,?,?,now(),now(),now(),now(),?,?) + """, officeId, accountId, currencyCode, transactionId, Date.valueOf(entryDate), typeEnum, amount, + currentUser.getId(), currentUser.getId(), Date.valueOf(DateUtils.getBusinessLocalDate()), Date.valueOf(entryDate)); + } + // ── POST /v1/branchday/close ────────────────────────────────────────────── @POST @@ -384,10 +557,17 @@ public class BranchDayRegisterApiResource { } final Long registerId = registerIds.get(0); + // Vault-only legs (OPENING_VAULT/CLOSING_VAULT - see VaultPositionService) live in this same + // table alongside the branch-total OPENING/CLOSING legs this endpoint is actually about. An + // office with a single teller records the same denomination breakdown under both pairs of + // legs, so an unfiltered "everything that isn't OPENING must be closing" catch-all silently + // folds the vault-only rows into "closing" too and doubles every count - explicit leg names, + // not an else branch, is what keeps this endpoint to the branch total it was always meant to + // report. final List> rows = jdbcTemplate.queryForList(""" SELECT leg, denomination_id, denomination_value, count, total_amount FROM m_branch_day_register_detail - WHERE register_id = ? + WHERE register_id = ? AND leg IN ('OPENING', 'CLOSING') ORDER BY denomination_value DESC """, registerId); @@ -707,6 +887,41 @@ public class BranchDayRegisterApiResource { return v == null ? BigDecimal.ZERO : v; } + /** + * Creates the office's own vault (teller) the first time its business day is ever opened, so a + * freshly set-up institution can go straight from Migrate Opening Balances to Open Business Day + * without a separate trip through Manage Tellers first. Left untouched once one exists — this + * only provisions, it never re-configures an office's existing vault setup. + */ + private void ensureTellerExists(final Long officeId, final LocalDate businessDate) { + final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?", + Integer.class, officeId); + if (existing != null && existing > 0) { + return; + } + + // The same GL account the branch's own cash-at-vault/cash-at-teller financial activities are + // mapped to (set up alongside the office's chart of accounts) - reusing it here is what keeps + // the vault's physical cash and the branch's own "Cash on Hand" ledger balance pointed at the + // same account, rather than inventing a second one nobody configured. + final Long cashAccountId = ((Number) resolveCashGlAccount().get("id")).longValue(); + + final String officeName = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, + officeId); + String tellerName = officeName + " Vault"; + final Integer nameTaken = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE name = ?", Integer.class, + tellerName); + if (nameTaken != null && nameTaken > 0) { + tellerName = tellerName + " " + officeId; + } + + jdbcTemplate.update(""" + INSERT INTO m_tellers (name, office_id, description, state, valid_from, debit_account_id, credit_account_id) + VALUES (?, ?, ?, 300, ?, ?, ?) + """, tellerName, officeId, "Auto-provisioned on first business day open", Date.valueOf(businessDate), + cashAccountId, cashAccountId); + } + private Long resolveOffice(final Long requested, final AppUser user) { if (requested != null) { return requested; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java index 4125e1df2..8453b6e92 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java @@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominati import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareAccountIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; @@ -81,6 +82,7 @@ import java.util.List; import java.util.Set; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; @@ -406,6 +408,28 @@ public class SavingsAccountAssembler { account.setShareLinkingEnabled(account.savingsProduct().isShareLinkingEnabled()); } + // Which share account this savings account is linked to - required once Share Linking is + // enabled, since a "Yes" with nothing chosen would leave the toggle meaning nothing. Checked + // against m_share_account directly (raw SQL, not the JPA entity - fineract-savings has no + // dependency on the shareaccounts domain class) rather than trusted blindly, the same way an + // account number typed for a linked savings/loan account elsewhere in this form is verified + // before being accepted. + if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) { + final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element); + if (shareAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required", + "A share account must be chosen when Share Linking is enabled."); + } + final Integer matches = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class, + shareAccountId, account.clientId()); + if (matches == null || matches == 0) { + throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client", + "Share account " + shareAccountId + " is not an active share account for this client."); + } + account.setShareAccountId(shareAccountId); + } + // Senior citizen rate — runs after validateDomainRules so it is the final persisted value if (command.parameterExists(isSeniorCitizenParamName)) { final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 002a025ed..b6268af91 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -116,6 +116,7 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl private final GroupRepositoryWrapper groupRepositoryWrapper; private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService; private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; @Transactional @Override @@ -257,6 +258,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl } final Long savingsId = account.getId(); + + // Mirrors the link back onto the share account this savings account was linked to at + // creation (share account -> savings account already existed; this is the reverse + // pointer), so either side can be navigated to from the other, the same way the "Default + // Savings Account" chosen on a share account points back at it. + if (account.getShareAccountId() != null) { + this.jdbcTemplate.update("UPDATE m_share_account SET savings_account_id = ? WHERE id = ?", savingsId, + account.getShareAccountId()); + } + if (command.parameterExists(SavingsApiConstants.datatables)) { this.entityDatatableChecksWritePlatformService.saveDatatables(StatusEnum.CREATE.getValue(), EntityTables.SAVINGS.getName(), savingsId, account.productId(), command.arrayOfParameterNamed(SavingsApiConstants.datatables)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index d732e933a..180bfc0da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -518,13 +518,13 @@ public class SavingsConfiguration { AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository, GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService, - ClientNomineeWritePlatformService clientNomineeWritePlatformService) { + ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) { return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler, savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository, businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper, - gsimWritePlatformService, clientNomineeWritePlatformService); + gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ClientActiveShareAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ClientActiveShareAccountsApiResource.java new file mode 100644 index 000000000..f5b35ab1f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ClientActiveShareAccountsApiResource.java @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * GET /v1/clients/{clientId}/activeshareaccounts + * + * A client's active share accounts, for the "Default Share Account" picker the Savings Account + * creation screen shows once Share Linking is enabled - the reverse of the "Default Savings + * Account" picker the Share Account creation screen already shows (which comes from + * {@code ShareAccountReadPlatformServiceImpl#retrieveActiveForLookup}). Deliberately its own small + * endpoint rather than folded into the savings account template response, which is shared by + * Savings, Fixed Deposit, Recurring Deposit and Current accounts alike - bolting a share-specific + * list onto that one shared, deeply-nested DTO would touch every one of those account types for a + * field only Savings actually uses. + */ +@Path("/v1/clients/{clientId}/activeshareaccounts") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class ClientActiveShareAccountsApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + @GET + public String retrieve(@PathParam("clientId") final Long clientId) { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id AS id, sa.account_no AS "accountNo", sp.name AS "productName" + FROM m_share_account sa JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.client_id = ? AND sa.status_enum = 300 + ORDER BY sa.account_no + """, clientId); + return gson.toJson(rows); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index fec686e0e..fa87d7cfc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -368,7 +368,7 @@ public class ShareAccount extends AbstractPersistableCustom { } public String getSavingsAccountNo() { - return this.savingsAccount.getAccountNumber(); + return this.savingsAccount == null ? null : this.savingsAccount.getAccountNumber(); } public void addAddtionalShares(Set additionalShares) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 30a3a348d..145123138 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -158,8 +158,10 @@ public class ShareAccountDataSerializer { baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank(); Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element); - baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull() - .longGreaterThanZero(); + if (savingsAccountId != null) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId) + .longGreaterThanZero(); + } final Long requestedShares = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull() .longGreaterThanZero(); @@ -200,12 +202,15 @@ public class ShareAccountDataSerializer { } Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId); - if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId, DepositAccountType.SAVINGS_DEPOSIT, - shareProduct.getCurrency().getCode())) { - throw new SavingsAccountNotFoundException(savingsAccountId); + SavingsAccount savingsAccount = null; + if (savingsAccountId != null) { + if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId, + DepositAccountType.SAVINGS_DEPOSIT, shareProduct.getCurrency().getCode())) { + throw new SavingsAccountNotFoundException(savingsAccountId); + } + savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId, + DepositAccountType.SAVINGS_DEPOSIT); } - SavingsAccount savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId, - DepositAccountType.SAVINGS_DEPOSIT); final MonetaryCurrency currency = shareProduct.getCurrency(); Set charges = assembleListOfAccountCharges(element, currency.getCode()); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 09a09f1a0..ed092bed4 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -50,4 +50,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_seed_basoda_bank_income_expense_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_seed_basoda_bank_income_expense_accounts.xml new file mode 100644 index 000000000..7cfe456fe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_seed_basoda_bank_income_expense_accounts.xml @@ -0,0 +1,45 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '301000' + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '401000' + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java index 599ac65c7..83a44b611 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountConstant.java @@ -48,7 +48,8 @@ public class SavingsAccountConstant extends SavingsApiConstants { introducerIdParamName, introducerNameParamName, introducerCifNumberParamName, nomineeParamName, "nominees", linkedAccountIdParamName, linkedAccountTypeParamName, linkedAccountNoParamName, accountFacilityConfigParamName, jointHoldersParamName, - balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName)); + balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName, + shareAccountIdParamName)); /** * These parameters will match the class level parameters of {@link SavingsAccountData}. Where possible, we try to diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 5c6eea230..3eb427dcf 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -4426,6 +4426,16 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom @Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false") private Boolean shareLinkingEnabled = false; + /** + * Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain + * class (it lives in fineract-provider, which depends on fineract-savings, not the other way + * round). The write side resolves and validates this id against m_share_account directly; this + * column just carries it back to the share account's own {@code savings_account_id}, mirroring + * the link from both ends. + */ + @Column(name = "share_account_id") + private Long shareAccountId; + @Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false") private Boolean nominationFacility = false; @@ -4532,6 +4542,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; } public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; } + public Long getShareAccountId() { return shareAccountId; } + public void setShareAccountId(Long v) { this.shareAccountId = v; } + public boolean isSeniorCitizen() { return isSeniorCitizen; } public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; } diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index ec16ad31d..d2b0790a5 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -53,4 +53,5 @@ + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_add_share_account_id_to_savings_account.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_add_share_account_id_to_savings_account.xml new file mode 100644 index 000000000..bcb206aae --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_add_share_account_id_to_savings_account.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + From c4a99fea5d448e6577728528806c9274e9a64a93 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 1 Sep 2026 07:40:39 +0530 Subject: [PATCH 24/55] feat(savings,clerkwithdrawal): expose linked share account on savings read, add search filter to withdrawal request lists Surfaces shareAccountId/shareAccountNo on the savings account read API so the UI can display and auto-select an already-linked share account; adds an optional search query param to the clerk cash and share withdrawal request list endpoints for the Approval Center's new search boxes. --- .../savings/data/SavingsAccountData.java | 2 ++ .../ClerkShareWithdrawalRequestApiResource.java | 16 ++++++++++++---- .../api/ClerkWithdrawalRequestApiResource.java | 11 ++++++++++- .../SavingsAccountReadPlatformServiceImpl.java | 4 ++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index bafe4a1c7..f5c9b2716 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable { private LocalDate endDate; private Boolean introducerRequired; private Boolean shareLinkingEnabled; + private Long shareAccountId; + private String shareAccountNo; private Boolean nominationFacility; private Boolean dormancyAlert; private Boolean isDormancyTrackingActiveFromProduct; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 2b3cf5a23..5f3da17da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -208,8 +208,9 @@ public class ClerkShareWithdrawalRequestApiResource { @Produces(MediaType.APPLICATION_JSON) public String list(@QueryParam("status") @DefaultValue("PENDING") final String status, @QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit, - @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { - return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit); + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("search") final String search) { + return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit); } // ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ────── @@ -227,11 +228,11 @@ public class ClerkShareWithdrawalRequestApiResource { where.append(" AND status = ?"); params.add(status); } - return queryList(where.toString(), params, fromDate, toDate, offset, limit); + return queryList(where.toString(), params, fromDate, toDate, null, offset, limit); } private String queryList(final String baseWhere, final List baseParams, final String fromDate, final String toDate, - final int offset, final int limit) { + final String search, final int offset, final int limit) { final StringBuilder where = new StringBuilder(baseWhere); final List params = new ArrayList<>(baseParams); if (fromDate != null && !fromDate.isBlank()) { @@ -242,6 +243,13 @@ public class ClerkShareWithdrawalRequestApiResource { where.append(" AND DATE(submitted_at) <= ?"); params.add(Date.valueOf(toDate)); } + if (search != null && !search.isBlank()) { + where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + params.add(like); + params.add(like); + params.add(like); + } final long total = Optional .ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 03dcd8d24..7765e417e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -158,7 +158,8 @@ public class ClerkWithdrawalRequestApiResource { @QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate, - @QueryParam("toDate") final String toDate) { + @QueryParam("toDate") final String toDate, + @QueryParam("search") final String search) { final StringBuilder where = new StringBuilder("WHERE status = ?"); final List baseParams = new ArrayList<>(List.of(status)); @@ -170,6 +171,14 @@ public class ClerkWithdrawalRequestApiResource { where.append(" AND DATE(submitted_at) <= ?"); baseParams.add(Date.valueOf(toDate)); } + if (search != null && !search.isBlank()) { + where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + baseParams.add(like); + baseParams.add(like); + baseParams.add(like); + baseParams.add(like); + } final long total = Optional.ofNullable( jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 6ade6bfff..4205b3804 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -883,6 +883,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, "); sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, "); sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, "); + sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, "); // Facility config fields sqlBuilder.append("safc.atm_facility as atmFacility, "); sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, "); @@ -915,6 +916,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid "); sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id "); + sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id "); this.schemaSql = sqlBuilder.toString(); } @@ -1141,6 +1143,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null); accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null); accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null); + accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId")); + accountData.setShareAccountNo(rs.getString("shareAccountNo")); accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null); // Nominee as parsed map From fd91da3d7628fabb4bd893864335edc2b18990ca Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 1 Sep 2026 15:20:08 +0530 Subject: [PATCH 25/55] feat(savings,cashier): maker-checker cash closure with vouchers and rounding Closing a savings account paid real cash out of a teller's drawer in one click, with no maker-checker, no cash-position tracking, and no way to hand over a paisa balance (no paisa coins circulate, and the denomination writer demands an exact match). This adds a Clerk -> Manager -> Cashier flow for Cash closures, modelled on the existing Clerk Withdrawal Request. New ClerkClosureRequestApiResource: - submit: Cash only, token required, guards ACTIVE status and open requests - approve: runs Fineract's own close command (pro-rata interest and the real GL withdrawal post here, atomically), reads the posted transaction back, rounds the payout to whole rupees and posts the difference to a new Round Off Account. A nil-balance closure goes straight to CLOSED rather than parking forever in a cashier queue with no cash to hand over. - disburse: cashier records the denomination breakdown against the rounded figure and the account closes - preview: dry-runs the very same interest posting close() performs, inside a REQUIRES_NEW transaction that is rolled back, so the figure shown to the clerk matches the real payout to the paisa. Reading the stored accrual instead gave a days-stale number, and CALCULATEINTEREST is not equivalent: it uses MathContext(15) and no postInterestOnDate, where close() uses MathContext(10) and feeds the closing date in as a posting-period boundary, which changes how many per-period roundings happen. Vouchers: close() reaches handleWithdrawal directly rather than through the withdrawal command handler, so the handler's own raiseSavingsVoucher never ran and a closure paid cash out with nothing in the voucher register. One is now raised at disbursement -- when the cash actually crosses the counter, in the cashier's name -- with three lines that match the posted GL exactly: Dr Savings Control + Dr Round Off = Cr Cash. Cash direction: the rounding adjustment debited Cash where cash leaving the drawer is a credit, leaving the cash GL disagreeing with what was physically handed over. Both legs corrected. Dates: closure_date, closedOnDate, the preview and the rounding entry now all use the CBS business date, so preview and approval cannot land on different days across a day roll. Day Book: closures were absent entirely (it reads the request registers, not m_voucher). Added as a fourth register, restricted to CLOSED rows and dated on disbursed_at, since a closure only becomes a cash-book event at hand-over. Its terminal status CLOSED was also missing from the totals gate, so a completed closure moved real money out without appearing in the DR total. Co-Authored-By: Claude Opus 5 (1M context) --- .../cashreport/api/CashReportApiResource.java | 49 +- .../api/ClerkClosureRequestApiResource.java | 1039 +++++++++++++++++ .../ClerkWithdrawalRequestApiResource.java | 16 +- .../voucher/service/VoucherService.java | 1 + .../tenant/final-changelog-tenant.xml | 3 + .../0525_create_clerk_closure_request.xml | 75 ++ .../parts/0526_seed_round_off_account.xml | 24 + .../0527_clerk_closure_request_voucher_no.xml | 22 + 8 files changed, 1218 insertions(+), 11 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 794cf15c6..15d6924a5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -94,6 +94,34 @@ public class CashReportApiResource { WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Account closures — a full-balance payout, so the debit side. Its own register rather than + // the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever + // close() computes at approval) and the cash handed over is the ROUNDED + // cashier_disbursed_amount, which is what a cash book has to agree with, not the + // paisa-exact closing_amount posted to the member's ledger. + // + // Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the + // cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has + // taken nothing out of the drawer yet, and a REJECTED one never will — listing those would + // put rows with no cash behind them (a rejected closure has no amount at all) into the + // teller's day book. And the day they belong to is the day the cash left, so this filters on + // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier + // step to reach. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no, + 'SAVINGS' AS account_type, r.client_name AS party, + COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, + r.payment_type_name, r.description, r.status, + COALESCE(r.disbursed_by, r.clerk_name) AS handled_by, + r.manager_name AS approved_by, r.tok_no AS txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_closure_request r + """ + verifyJoin + """ + WHERE r.status = 'CLOSED' + AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a // bank transfer moves it out (DR) — matching how the voucher itself was numbered. rows.addAll(jdbcTemplate.query(""" @@ -128,13 +156,15 @@ public class CashReportApiResource { filtered.add(row); // Only approved money is real money: a pending or rejected voucher must not inflate the - // day's totals, even though it still belongs in the listing. The three source registers - // don't share one terminal status string: deposits and disbursements land on APPROVED, - // but a clerk withdrawal's own finished state is WITHDRAWN -- missing it here meant a - // completed withdrawal moved real money out yet never showed up in the DR total at all. + // day's totals, even though it still belongs in the listing. The source registers don't + // share one terminal status string: deposits and disbursements land on APPROVED, a clerk + // withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing + // any of them meant a completed transaction moved real money out yet never showed up in + // the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting + // both states cannot double-count it; only 'CLOSED' is unique to that register. final String rowStatus = (String) row.get("status"); if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) - || "WITHDRAWN".equalsIgnoreCase(rowStatus)) { + || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { if ("CR".equals(row.get("side"))) { @@ -833,6 +863,12 @@ public class CashReportApiResource { + " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' " + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) " + // An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row + // (the column records which way cash moved), but its request lives in its own + // register -- without this join the closure's real voucher number is never found and + // the report falls back to showing the bare savings-transaction id instead. + + "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND " + + " ccr.txn_id = CAST(std.txn_id AS VARCHAR) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; @@ -853,7 +889,8 @@ public class CashReportApiResource { // 2. Paged data query final String dataSql = "SELECT " - + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), " + + " CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " std.account_no AS ac_no, " + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " MAX(std.txn_date) AS txn_date, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java new file mode 100644 index 000000000..4d3d6f201 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -0,0 +1,1039 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.clerkwithdrawal.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +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.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s + * shape: a clerk submits a request (account + token, no amount -- unknown until approval), a bank + * manager approves it (which runs Fineract's own {@code close} command -- pro-rata interest, TDS and + * the real GL withdrawal all post atomically at that moment, exactly as they would from the ordinary + * Close Savings Account screen), and a cashier hands over the physical cash against the token, + * recording the denomination breakdown. + * + * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the + * nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated + * "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own + * ledger (which stays exactly correct to the paisa). + */ +@Path("/v1/clerk/closure-requests") +@Component +@Tag(name = "Clerk Closure Requests", description = "Maker-checker Cash-only savings account closure workflow for clerk, bank manager and cashier.") +@RequiredArgsConstructor +public class ClerkClosureRequestApiResource { + + // Named LOG rather than @Slf4j: buildActivityLog uses a local "log" for the activity JsonArray, + // and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class); + + private final JdbcTemplate jdbcTemplate; + private final FromJsonHelper fromJsonHelper; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + // Used only by preview(), to compute the closing figure the way close() will actually compute it. + private final SavingsAccountAssembler savingsAccountAssembler; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; + private final VoucherService voucherService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── POST /v1/clerk/closure-requests ── Clerk submits new request ──────────── + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String submit(final String body) { + final AppUser currentUser = context.authenticatedUser(); + final JsonElement root = fromJsonHelper.parse(body); + + final Long savingsAccountId = fromJsonHelper.extractLongNamed("savingsAccountId", root); + final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root); + final String paymentTypeName = Optional.ofNullable(fromJsonHelper.extractStringNamed("paymentTypeName", root)).orElse("Cash"); + final String description = fromJsonHelper.extractStringNamed("description", root); + final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); + + if (savingsAccountId == null) { + throw apiError(400, "Savings account id is required for a closure request"); + } + // Cash only for now -- Transfer/Cheque/NEFT/RTGS still go through the direct Close Savings + // Account screen, which has no physical cash to track and needs no cashier step. + if (!isCashPaymentType(paymentTypeId)) { + throw apiError(400, + "Only Cash closures are supported here for now. For Transfer/Cheque/NEFT/RTGS, use the " + + "Close Savings Account screen directly from the account's own page."); + } + if (tokNo == null || tokNo.isBlank()) { + throw apiError(400, "Token number (TOK. NO.) is required for a cash closure."); + } + validateTokenUniqueToday(tokNo); + + final Map account = loadSavingsAccountSummary(savingsAccountId); + if (account == null) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Integer statusEnum = ((Number) account.get("status_enum")).intValue(); + if (statusEnum != 300) { + throw apiError(409, "Only an ACTIVE account can be closed. Current status: " + account.get("status_enum")); + } + final long openRequests = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request WHERE savings_account_id = ? AND status IN ('PENDING','APPROVED')", + Long.class, savingsAccountId) + ).orElse(0L); + if (openRequests > 0) { + throw apiError(409, "This account already has an open closure request (pending or awaiting cashier)."); + } + + final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); + + final Long newId = jdbcTemplate.queryForObject(""" + INSERT INTO m_clerk_closure_request + (savings_account_id, account_no, client_id, client_name, closure_date, + payment_type_id, payment_type_name, description, tok_no, + status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, + Long.class, + savingsAccountId, account.get("account_no"), account.get("client_id"), account.get("client_name"), + Date.valueOf(DateUtils.getBusinessLocalDate()), + paymentTypeId, paymentTypeName, description, tokNo, + "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + activityLog, Timestamp.from(Instant.now()) + ); + + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + } + + // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── + + /** + * "What would this member actually receive" preview, so a clerk sees the real closing figure + * before submitting. + * + * This must reproduce what close() will pay out, to the paisa, and reading the stored + * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last + * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is + * close but not identical -- it uses {@code MathContext(15)} and passes + * {@code postInterestOnDate = null}, whereas close() calls + * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and + * feeds {@code closedDate} in as an extra posting-period boundary + * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period + * changes how many per-period roundings happen, so the two can disagree by paise. + * + * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method + * close() ends up in with the same arguments, read the resulting balance -- which is exactly the + * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right + * after posting interest) -- and then roll the whole thing back. + * + * Why this is safe to run per keystroke: + *
    + *
  • The domain {@code postInterest} touches nothing outside the entity graph: no repository + * save, no journal entry, no voucher, no event. All of that lives in the service layer, which is + * deliberately bypassed here.
  • + *
  • REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the + * dirtied entities live in a separate persistence context that is discarded.
  • + *
  • Config is read before the account is assembled: a cache-miss JPA query afterwards could + * auto-flush the in-memory interest transactions, and while the rows would roll back, the + * identity-sequence values they consumed would not.
  • + *
+ */ + @GET + @Path("/preview/{savingsAccountId}") + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(sa.account_balance_derived, 0) AS balance + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE sa.id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Map account = rows.get(0); + // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / + // RecurringDepositAccount, whose interest and tax on closure run through + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below + // would quietly compute the wrong number for them. + final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); + if (depositType != 100) { + throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); + } + + final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); + // The CBS business date, not wall-clock: this is the date the closure will actually post on + // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up + // to. Using LocalDate.now() here would silently disagree with approval around a day roll. + final LocalDate closureDate = DateUtils.getBusinessLocalDate(); + + // Read configuration first -- see the flush note in the javadoc. + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); + + final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); + dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + BigDecimal[] figures = null; + try { + figures = dryRun.execute(status -> { + final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); + // The exact call close() makes, via postInterest(account, true, closedDate, false). + acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, closureDate, false, false); + final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); + status.setRollbackOnly(); + return new BigDecimal[] { before, after }; + }); + } catch (final RuntimeException e) { + // Never cost the clerk the preview entirely -- fall through to the stored balance and + // flag that the interest figure could not be computed. + figures = null; + } + + final boolean computed = figures != null; + final BigDecimal balance = computed ? figures[0] : storedBalance; + final BigDecimal total = computed ? figures[1] : storedBalance; + final BigDecimal interest = total.subtract(balance); + + final JsonObject res = new JsonObject(); + res.addProperty("savingsAccountId", savingsAccountId); + res.addProperty("accountNo", String.valueOf(account.get("account_no"))); + res.addProperty("clientName", account.get("client_name") == null ? "" : String.valueOf(account.get("client_name"))); + res.addProperty("statusEnum", ((Number) account.get("status_enum")).intValue()); + res.addProperty("balance", balance); + res.addProperty("accruedInterest", interest); + res.addProperty("total", total); + // What a cashier could physically count out: no paisa coins circulate, so the hand-over is + // always whole rupees (the difference lands on the Round Off Account at approval). + res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("interestUpToDate", computed); + res.addProperty("asOfDate", closureDate.toString()); + return gson.toJson(res); + } + + private BigDecimal zeroIfNull(final BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + // ── GET /v1/clerk/closure-requests ── Manager sees all requests ───────────── + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list( + @QueryParam("status") @DefaultValue("PENDING") final String status, + @QueryParam("offset") @DefaultValue("0") final int offset, + @QueryParam("limit") @DefaultValue("20") final int limit, + @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate, + @QueryParam("search") final String search) { + + final StringBuilder where = new StringBuilder("WHERE status = ?"); + final List baseParams = new ArrayList<>(List.of(status)); + if (fromDate != null && !fromDate.isBlank()) { + where.append(" AND DATE(submitted_at) >= ?"); + baseParams.add(Date.valueOf(fromDate)); + } + if (toDate != null && !toDate.isBlank()) { + where.append(" AND DATE(submitted_at) <= ?"); + baseParams.add(Date.valueOf(toDate)); + } + if (search != null && !search.isBlank()) { + where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR tok_no ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + baseParams.add(like); + baseParams.add(like); + baseParams.add(like); + } + + final long total = Optional.ofNullable( + jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_closure_request " + where, Long.class, baseParams.toArray()) + ).orElse(0L); + + final List pageParams = new ArrayList<>(baseParams); + pageParams.add(limit); + pageParams.add(offset); + + final List> rows = jdbcTemplate.queryForList( + "SELECT id, savings_account_id, account_no, client_id, client_name, closure_date," + + " payment_type_id, payment_type_name, description, tok_no, status," + + " clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username," + + " rejection_reason, txn_id, closing_amount, rounding_amount, cashier_disbursed_amount," + + " cashier_id, disbursed_by, disbursed_at, voucher_no, submitted_at, actioned_at" + + " FROM m_clerk_closure_request " + where + + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", + pageParams.toArray()); + + final JsonObject result = new JsonObject(); + result.addProperty("totalFilteredRecords", total); + result.add("pageItems", gson.toJsonTree(rows)); + return gson.toJson(result); + } + + // ── GET /v1/clerk/closure-requests/my ── Clerk's own requests ─────────────── + + @GET + @Path("/my") + @Produces(MediaType.APPLICATION_JSON) + public String myRequests( + @QueryParam("status") final String status, + @QueryParam("offset") @DefaultValue("0") final int offset, + @QueryParam("limit") @DefaultValue("20") final int limit, + @QueryParam("fromDate") final String fromDate, + @QueryParam("toDate") final String toDate) { + + final AppUser currentUser = context.authenticatedUser(); + final StringBuilder where = new StringBuilder("WHERE clerk_id = ?"); + final List params = new ArrayList<>(List.of(currentUser.getId())); + + if (status != null && !status.isBlank()) { + where.append(" AND status = ?"); + params.add(status); + } + if (fromDate != null && !fromDate.isBlank()) { + where.append(" AND DATE(submitted_at) >= ?"); + params.add(Date.valueOf(fromDate)); + } + if (toDate != null && !toDate.isBlank()) { + where.append(" AND DATE(submitted_at) <= ?"); + params.add(Date.valueOf(toDate)); + } + + final long total = Optional.ofNullable( + jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_closure_request " + where, Long.class, params.toArray()) + ).orElse(0L); + + final List pageParams = new ArrayList<>(params); + pageParams.add(limit); + pageParams.add(offset); + + final List> rows = jdbcTemplate.queryForList( + """ + SELECT id, savings_account_id, account_no, client_name, closure_date, + payment_type_name, description, tok_no, status, + clerk_name, manager_name, rejection_reason, txn_id, + closing_amount, rounding_amount, cashier_disbursed_amount, + voucher_no, submitted_at, actioned_at + FROM m_clerk_closure_request + """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", + pageParams.toArray() + ); + + final JsonObject result = new JsonObject(); + result.addProperty("totalFilteredRecords", total); + result.add("pageItems", gson.toJsonTree(rows)); + return gson.toJson(result); + } + + // ── GET /v1/clerk/closure-requests/{id} ── Full detail ────────────────────── + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public String getById(@PathParam("id") final Long id) { + return gson.toJson(loadRecord(id)); + } + + // ── GET /v1/clerk/closure-requests/by-token/{token} ── Cashier token lookup ── + + @GET + @Path("/by-token/{token}") + @Produces(MediaType.APPLICATION_JSON) + public String getByToken(@PathParam("token") final String token) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, savings_account_id, account_no, client_id, client_name, + payment_type_id, payment_type_name, tok_no, status, + clerk_id, clerk_name, manager_id, manager_name, + closing_amount, rounding_amount, cashier_disbursed_amount, + voucher_no, submitted_at, actioned_at + FROM m_clerk_closure_request + WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE + ORDER BY submitted_at DESC LIMIT 1 + """, token); + + if (rows.isEmpty()) { + throw apiError(404, "Token '" + token + "' not found for today. Please check the token number."); + } + + final String status = String.valueOf(rows.get(0).get("status")); + return switch (status) { + case "APPROVED" -> gson.toJson(rows.get(0)); + case "PENDING" -> throw apiError(422, + "Token '" + token + "' is still PENDING — manager has not approved this closure yet."); + case "CLOSED" -> throw apiError(422, + "Token '" + token + "' has already been closed. Cash was handed over by another cashier."); + case "REJECTED" -> throw apiError(422, + "Token '" + token + "' was REJECTED by the manager. This closure cannot be processed."); + default -> throw apiError(422, "Token '" + token + "' has unexpected status: " + status); + }; + } + + // ── POST /v1/clerk/closure-requests/{id}/approve ── Manager approves ──────── + + @POST + @Path("/{id}/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approve(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = context.authenticatedUser(); + validateCheckerRole(currentUser); + final Map record = loadRecord(id); + + if (!"PENDING".equals(record.get("status"))) { + throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status")); + } + final Long makerId = ((Number) record.get("clerk_id")).longValue(); + if (makerId.equals(currentUser.getId())) { + throw apiError(403, + "Maker-checker: you cannot approve a closure request you submitted yourself. Ask another manager to review it."); + } + + final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + final Map account = loadSavingsAccountSummary(savingsAccountId); + if (account == null) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Integer statusEnum = ((Number) account.get("status_enum")).intValue(); + if (statusEnum != 300) { + throw apiError(409, "Account is no longer ACTIVE (status " + statusEnum + ") — cannot close."); + } + + // Fineract's own close command: pro-rata interest, TDS and the real GL withdrawal all post + // atomically here -- exactly as they would from the ordinary Close Savings Account screen. + executeClose(savingsAccountId); + + // Read back the withdrawal transaction close() just posted -- its CommandProcessingResult + // doesn't return the transaction id/amount directly. transaction_type_enum 2 = WITHDRAWAL. + final Map txn = jdbcTemplate.queryForList(""" + SELECT id, amount, office_id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = 2 AND is_reversed = false + ORDER BY id DESC LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(null); + + String txnId = null; + BigDecimal closingAmount = BigDecimal.ZERO; + BigDecimal roundingAmount = BigDecimal.ZERO; + BigDecimal cashierDisbursedAmount = BigDecimal.ZERO; + + if (txn != null) { + txnId = txn.get("id").toString(); + closingAmount = new BigDecimal(txn.get("amount").toString()); + cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + roundingAmount = cashierDisbursedAmount.subtract(closingAmount); + + if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) { + final Long officeId = ((Number) txn.get("office_id")).longValue(); + postRoundingAdjustment(officeId, roundingAmount, currentUser); + } + } + + // A closure with nothing to pay out (an already-empty account) has no cashier step to wait + // for -- the account is closed the moment this is approved. Leaving it APPROVED would park + // it in the cashier's token queue forever with zero cash to hand over. + final boolean awaitingCashier = cashierDisbursedAmount.compareTo(BigDecimal.ZERO) > 0; + final String newStatus = awaitingCashier ? "APPROVED" : "CLOSED"; + + // No voucher here on purpose: a voucher records cash actually crossing the counter, and at + // approval nothing has left the drawer yet -- the cashier still has to hand the notes over + // against the token. It is raised in disburse(), in the cashier's own name. A nil-balance + // closure therefore never gets one, which is right: no cash ever moved. + final String updatedLog = buildActivityLog( + (String) record.get("activity_log"), newStatus, currentUser.getUsername(), + awaitingCashier ? null : "Nil balance on closure — no cash hand-over required." + ); + + final int approvedRows = jdbcTemplate.update(""" + UPDATE m_clerk_closure_request SET + status = ?, manager_id = ?, manager_name = ?, manager_username = ?, + txn_id = ?, closing_amount = ?, rounding_amount = ?, cashier_disbursed_amount = ?, + activity_log = ?, actioned_at = ? + WHERE id = ? AND status = 'PENDING' + """, + newStatus, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + txnId, closingAmount, roundingAmount, cashierDisbursedAmount, + updatedLog, Timestamp.from(Instant.now()), id + ); + if (approvedRows == 0) { + throw apiError(409, "This closure request was already actioned by another manager."); + } + + final JsonObject res = new JsonObject(); + res.addProperty("resourceId", id); + res.addProperty("status", newStatus); + res.addProperty("closingAmount", closingAmount); + res.addProperty("roundingAmount", roundingAmount); + res.addProperty("cashierDisbursedAmount", cashierDisbursedAmount); + return gson.toJson(res); + } + + // ── POST /v1/clerk/closure-requests/{id}/reject ── Manager rejects ────────── + + @POST + @Path("/{id}/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String reject(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = context.authenticatedUser(); + validateCheckerRole(currentUser); + final Map record = loadRecord(id); + + if (!"PENDING".equals(record.get("status"))) { + throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status")); + } + + final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"); + final String reason = fromJsonHelper.extractStringNamed("reason", root); + if (reason == null || reason.isBlank()) { + throw apiError(400, "Rejection reason is required"); + } + + final String updatedLog = buildActivityLog( + (String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason + ); + + final int rejectedRows = jdbcTemplate.update(""" + UPDATE m_clerk_closure_request SET + status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?, + rejection_reason = ?, activity_log = ?, actioned_at = ? + WHERE id = ? AND status = 'PENDING' + """, + currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + reason, updatedLog, Timestamp.from(Instant.now()), id + ); + if (rejectedRows == 0) { + throw apiError(409, "This closure request was already actioned by another manager."); + } + + return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); + } + + // ── POST /v1/clerk/closure-requests/{id}/disburse ── Cashier hands over cash ─ + + @POST + @Path("/{id}/disburse") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String disburse(@PathParam("id") final Long id, final String body) { + final AppUser currentUser = context.authenticatedUser(); + final Map record = loadRecord(id); + + if (!"APPROVED".equals(record.get("status"))) { + throw apiError(409, "Only APPROVED requests can be disbursed. Current status: " + record.get("status")); + } + + String denominationData = null; + BigDecimal transactionAmount = null; + if (body != null && !body.isBlank()) { + final JsonElement root = fromJsonHelper.parse(body); + denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + transactionAmount = parseBigDecimal(fromJsonHelper.extractStringNamed("transactionAmount", root)); + } + + if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement."); + } + + // Cashier hands over the ROUNDED figure (whole rupees), not the paisa-exact closing_amount. + final BigDecimal approvedAmount = new BigDecimal(record.get("cashier_disbursed_amount").toString()); + if (approvedAmount.compareTo(transactionAmount) != 0) { + throw apiError(400, + "Transaction amount ₹" + transactionAmount + + " does not match the approved cash-to-hand-over amount ₹" + approvedAmount + + ". Cash denomination total must equal it exactly."); + } + + final long denomTotal = denominationTotal(denominationData); + if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) { + throw apiError(400, + "Cash denomination total ₹" + denomTotal + " does not match disbursement amount ₹" + + transactionAmount + ". Notes/coins must equal the approved amount exactly."); + } + if (denomTotal == 0) { + throw apiError(400, "Cash disbursement requires the denomination breakdown (notes/coins)."); + } + + final String updatedLog = buildActivityLog( + (String) record.get("activity_log"), "CLOSED", currentUser.getUsername(), null + ); + + final int updated = jdbcTemplate.update(""" + UPDATE m_clerk_closure_request SET + status = 'CLOSED', cashier_id = ?, disbursed_by = ?, + disbursement_denomination_data = ?, activity_log = ?, disbursed_at = ? + WHERE id = ? AND status = 'APPROVED' + """, + currentUser.getId(), currentUser.getDisplayName(), + denominationData, updatedLog, Timestamp.from(Instant.now()), id + ); + if (updated == 0) { + throw apiError(409, "This closure has already been completed by another cashier. Double disbursement prevented."); + } + + if (denominationData != null && !denominationData.isBlank()) { + saveDisbursementDenomination(denominationData, record, currentUser); + } + + // The voucher belongs to this moment -- the cash has now physically crossed the counter -- + // and to this user, the cashier who counted it out. + final Object txnIdObj = record.get("txn_id"); + final String voucherNo = raiseClosureVoucher(id, + ((Number) record.get("savings_account_id")).longValue(), + txnIdObj == null ? null : Long.valueOf(txnIdObj.toString()), + record, new BigDecimal(record.get("closing_amount").toString()), + record.get("rounding_amount") == null ? BigDecimal.ZERO + : new BigDecimal(record.get("rounding_amount").toString()), + approvedAmount, currentUser); + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_clerk_closure_request SET voucher_no = ? WHERE id = ?", voucherNo, id); + } + + return gson.toJson(Map.of("resourceId", id, "status", "CLOSED", + "voucherNo", voucherNo != null ? voucherNo : "")); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void executeClose(final Long savingsAccountId) { + // Business date, matching what preview() computed the figure against. + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("closedOnDate", today); + // Unconditional and never skippable -- closure always withdraws the full balance. + payload.addProperty("withdrawBalance", true); + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .closeSavingsAccountApplication(savingsAccountId) + .withJson(payload.toString()) + .build(); + + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + if (result == null) { + throw apiError(500, "Closure command did not return a result for account " + savingsAccountId); + } + } + + /** + * Raises the closure's entry in the central voucher register, mirroring what an ordinary counter + * withdrawal gets. Closing an account bypasses the {@code withdrawal} command handler -- close() + * calls {@code savingsAccountDomainService.handleWithdrawal} directly -- so the generic + * {@code raiseSavingsVoucher} on that handler never runs and a closure would otherwise pay real + * cash out with nothing in the voucher register at all. + * + * Three lines, because the cash handed over is the ROUNDED figure while the member's ledger gave + * up the exact one: + *
+     *   Dr  Savings Control   closing_amount            (money leaving the member)
+     *   Dr  Round Off Account rounding_amount           (only when rounded up)
+     *   Cr  Cash in Hand      cashier_disbursed_amount  (what the till actually pays out)
+     * 
+ * Rounded down, the Round Off line moves to the credit side instead. This matches the journal + * entries close() and postRoundingAdjustment() actually post. + * + * Called from disburse(), not approve(): a voucher records cash crossing the counter, so it is + * raised when the cashier actually hands the notes over, in that cashier's own name. A closure + * that never reaches a cashier (a nil-balance account, closed outright at approval) correctly + * gets no voucher, because no cash ever moved. + * + * A failure here must not fail the disbursement: the cash is already in the member's hands and + * the account is closed. The problem is logged and the request keeps its (null) voucher number. + */ + private String raiseClosureVoucher(final Long requestId, final Long savingsAccountId, final Long savingsTxnId, + final Map record, final BigDecimal closingAmount, final BigDecimal roundingAmount, + final BigDecimal cashierDisbursedAmount, final AppUser currentUser) { + if (!voucherService.shouldRaiseVoucher() || closingAmount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + try { + final Map control = voucherService.savingsControlAccount(savingsAccountId); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) + : (String) control.get("name"); + final String accountNo = (String) record.get("account_no"); + + final List lines = new ArrayList<>(); + lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); + final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); + if (roundingDirection > 0) { + lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + } + lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); + if (roundingDirection < 0) { + lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + } + + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, + VoucherService.REF_CLERK_CLOSURE_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + // Tie the voucher to the savings transaction close() posted, so the two reconcile from + // either side -- this also stamps voucher_no onto the transaction itself. + voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_CLOSURE_REQUEST, requestId, savingsTxnId); + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for closure request {}", requestId, e); + return null; + } + } + + /** + * Posts one small adjusting journal entry so the bank's own Cash GL absorbs the paise rounded + * away for physical hand-over, without ever touching the member's own (paisa-correct) ledger. + * + * Cash is an asset, so cash LEAVING the drawer is a CREDIT to it — which is exactly what + * close() itself posts for the withdrawal (Dr Savings Control / Cr Cash). A positive + * roundingAmount means the cashier hands over MORE than the exact balance (rounded up), so that + * extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the + * difference as an expense). Rounded down, both legs flip. + */ + private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) { + final Map cashGl = resolveCashGlAccount(); + final Long roundOffGlId = resolveRoundOffGlAccount(); + final String currencyCode = jdbcTemplate.queryForObject( + "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); + final BigDecimal amount = roundingAmount.abs(); + // Same business date as the closure this is adjusting, so the two never land on + // different days in the day book. + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime(); + + final Long cashGlId = ((Number) cashGl.get("id")).longValue(); + // type_enum: 2 = DEBIT, 1 = CREDIT. + if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) { + // Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash + insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); + insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); + } else { + // Rounded down: less cash left the drawer than the member's ledger gave up. + // Dr Cash / Cr Round Off + insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); + insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); + } + } + + private Map resolveCashGlAccount() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT g.id, g.name FROM acc_gl_financial_activity_account f + JOIN acc_gl_account g ON g.id = f.gl_account_id + WHERE f.financial_activity_type IN (101, 102) + ORDER BY f.financial_activity_type LIMIT 1 + """); + if (rows.isEmpty()) { + throw apiError(500, "No Cash at Vault / Cash at Teller account is configured for this tenant yet."); + } + return rows.get(0); + } + + private Long resolveRoundOffGlAccount() { + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class); + if (ids.isEmpty()) { + throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet."); + } + return ids.get(0); + } + + /** type_enum: 2 = DEBIT, 1 = CREDIT (matches org.apache.fineract.accounting.journalentry.domain.JournalEntryType). */ + private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, + final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, + final AppUser currentUser) { + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, + amount, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, + last_modified_on_utc, submitted_on_date, transaction_date) + VALUES (?,?,?,?,false,true,?,?,?,?,?,now(),now(),now(),now(),?,?) + """, officeId, accountId, currencyCode, transactionId, Date.valueOf(entryDate), typeEnum, amount, + currentUser.getId(), currentUser.getId(), Date.valueOf(DateUtils.getBusinessLocalDate()), Date.valueOf(entryDate)); + } + + private Map loadSavingsAccountSummary(final Long savingsAccountId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.status_enum, sa.client_id, + COALESCE(c.display_name, g.display_name) AS client_name + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE sa.id = ? + """, savingsAccountId); + return rows.isEmpty() ? null : rows.get(0); + } + + /** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */ + private long denominationTotal(final String denominationData) { + if (denominationData == null || denominationData.isBlank()) return 0; + final JsonObject denomMap; + try { + denomMap = gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + throw apiError(400, "denominationData is not valid JSON"); + } + if (denomMap == null) return 0; + long total = 0; + for (final Map.Entry entry : denomMap.entrySet()) { + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final int value; + final int count; + try { + value = Integer.parseInt(parts[1]); + count = entry.getValue().getAsInt(); + } catch (Exception e) { + continue; + } + if (count > 0) total += (long) value * count; + } + return total; + } + + private boolean isCashPaymentType(final Long paymentTypeId) { + if (paymentTypeId == null) return true; // null == "Cash (Default)" in the UI + final List rows = jdbcTemplate.queryForList( + "SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId); + return !rows.isEmpty() && Boolean.TRUE.equals(rows.get(0)); + } + + /** Saves the cashier's denomination breakdown into m_savings_txn_denomination so the vault + * position report reflects the cash paid out. denominationData: {"note:500": 2, ...}. */ + private void saveDisbursementDenomination(final String denominationData, final Map record, final AppUser cashier) { + final JsonObject denomMap; + try { + denomMap = gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + return; + } + if (denomMap == null || denomMap.size() == 0) return; + + final Object txnIdObj = record.get("txn_id"); + final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null; + if (txnId == null) return; + + final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + final String accountNo = (String) record.get("account_no"); + final Object clientIdObj = record.get("client_id"); + final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null; + final String paymentTypeName = (String) record.get("payment_type_name"); + + for (final Map.Entry entry : denomMap.entrySet()) { + final int count; + try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } + if (count <= 0) continue; + + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final String kind = parts[0]; + final int value; + try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } + + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" + + " AND (deleted IS NULL OR deleted = false) LIMIT 1", + Long.class, value, kind); + if (ids.isEmpty()) continue; + final Long denomId = ids.get(0); + + // txn_type is 'WITHDRAWAL', not a closure-specific label: this column records the + // direction cash moved through the drawer, and every consumer of this table (the branch + // day cash-out tally, the denomination and cashier-position reports, cash reconciliation, + // DenominationBreakdownWriter) filters on exactly 'DEPOSIT'/'WITHDRAWAL'. A new value + // here would leave this cash physically gone but invisible to all of them, so the day + // would never balance. The fact that it was a closure is recorded on + // m_clerk_closure_request itself, which is where that belongs. + jdbcTemplate.update(""" + INSERT INTO m_savings_txn_denomination + (txn_id, savings_account_id, account_no, client_id, denomination_id, + denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?) + """, + txnId, savingsAccountId, accountNo, clientId, denomId, + value, count, BigDecimal.valueOf((long) value * count), + cashier.getId(), cashier.getUsername(), paymentTypeName + ); + } + } + + private Map loadRecord(final Long id) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, savings_account_id, account_no, client_id, client_name, closure_date, + payment_type_id, payment_type_name, description, tok_no, status, + clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, + rejection_reason, txn_id, closing_amount, rounding_amount, cashier_disbursed_amount, + cashier_id, disbursed_by, disbursed_at, disbursement_denomination_data, + voucher_no, activity_log, submitted_at, actioned_at + FROM m_clerk_closure_request WHERE id = ? + """, id); + if (rows.isEmpty()) { + throw new NotFoundException("Closure request not found: " + id); + } + return rows.get(0); + } + + /** + * Ensures the token number is unique for today's date among non-rejected requests across all + * three token pools that share this app's Transaction Entry token field: this table, cash + * withdrawal requests, and share dividend claims. + */ + private void validateTokenUniqueToday(final String tokNo) { + final long own = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + final long withdrawal = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_withdrawal_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + final long dividend = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_dividend_claim_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + if (own + withdrawal + dividend > 0) { + throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number."); + } + } + + /** + * Checker authorization: only manager-level users may approve or reject. Allowed: super users + * (ALL_FUNCTIONS), or any role whose name contains "manager" or "head cashier". + */ + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream() + .map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject closure requests."); + } + } + + private BigDecimal parseBigDecimal(final String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return new BigDecimal(raw); + } catch (NumberFormatException e) { + return null; + } + } + + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { + JsonArray log = new JsonArray(); + if (existingJson != null && !existingJson.isBlank()) { + try { + log = fromJsonHelper.parse(existingJson).getAsJsonArray(); + } catch (Exception e) { + log = new JsonArray(); + } + } + final JsonObject entry = new JsonObject(); + entry.addProperty("timestamp", Instant.now().toString()); + entry.addProperty("action", action); + entry.addProperty("user", username); + if (note != null && !note.isBlank()) { + entry.addProperty("note", note); + } + log.add(entry); + return gson.toJson(log); + } + + /** Clean JSON error the UI can display -- avoids a 500 + stack trace from the default exception mapper. */ + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build() + ); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 7765e417e..2dafeb28b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -850,9 +850,9 @@ public class ClerkWithdrawalRequestApiResource { /** * Ensures the token number is unique for today's date among non-rejected requests, checking - * both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token - * is redeemed at the very same Transaction Entry field, so the two pools must never collide. - * {@code excludeId} is set during resubmit to skip the record being edited. + * this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} -- + * all three are redeemed at the very same Transaction Entry token field, so none of the pools + * may ever collide. {@code excludeId} is set during resubmit to skip the record being edited. */ private void validateTokenUniqueToday(final String tokNo, final Long excludeId) { final StringBuilder sql = new StringBuilder( @@ -867,13 +867,19 @@ public class ClerkWithdrawalRequestApiResource { final long ownCount = Optional.ofNullable( jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()) ).orElse(0L); - final long otherCount = Optional.ofNullable( + final long dividendCount = Optional.ofNullable( jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_share_dividend_claim_request " + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo) ).orElse(0L); - if (ownCount + otherCount > 0) { + final long closureCount = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + if (ownCount + dividendCount + closureCount > 0) { throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number."); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index e56d5981f..b07bb1061 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -63,6 +63,7 @@ public class VoucherService { public static final String TYPE_REPAYMENT = "REPAYMENT"; public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING"; public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST"; + public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index ed092bed4..5c0ea6560 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -51,4 +51,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml new file mode 100644 index 000000000..2413df6d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml new file mode 100644 index 000000000..713cc4924 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml @@ -0,0 +1,24 @@ + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '501200' + + + INSERT INTO acc_gl_account + (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) + VALUES ('Round Off Account', '501200', false, true, 1, 5); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml new file mode 100644 index 000000000..042b038f0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + From 942bad6c8cff417c6f31c78e6b21c5e0ffff0e00 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 1 Sep 2026 21:58:12 +0530 Subject: [PATCH 26/55] fix(branchday): count account closures in the day-close cash reconciliation Day Close reported SHORT by exactly the amount a closure had paid out. The "Cash paid out (approved withdrawals)" line only summed m_clerk_withdrawal_request, so a closure -- which pays cash over the same counter but lives in its own register -- was missing from the total. Expected cash stayed high by what had actually been handed over, and a closure that balanced perfectly read as a shortage. Verified against live data: opening 145,304 + deposits 0 - slip withdrawals 0 - closure 12,999 = 132,305, which is exactly the counted branch total. Variance goes from -12,999 to 0. Sums cashier_disbursed_amount rather than closing_amount: the member's ledger gives up the paisa-exact figure, but what physically leaves the drawer is that figure rounded to whole rupees, and this total has to agree with a physical count. Restricted to CLOSED rows dated on disbursed_at, since a closure takes nothing out of the drawer until the cashier hands it over. Note: dividend cash claims (m_share_dividend_claim_request) are also absent from this total and will misreport the same way. Left alone deliberately -- that register splits a claim across loan/savings/cash with its own cash_status column that nothing in the Java sets, so the right predicate cannot be established from the code as it stands. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/BranchDayRegisterApiResource.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index ab0a6d421..249622985 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -853,12 +853,29 @@ public class BranchDayRegisterApiResource { // disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on // APPROVED alone silently excluded every completed withdrawal from this total, overstating // the branch's expected cash by exactly what had actually been paid out that day. - final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject(""" + final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request WHERE status = 'WITHDRAWN' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? """, BigDecimal.class, date)); + // Account closures pay cash over the same counter but live in their own register, so they + // were missing from this total entirely -- the branch's expected cash stayed high by exactly + // what had been handed over, reporting a shortage for a closure that balanced perfectly. + // + // cashier_disbursed_amount, not closing_amount: the member's ledger gives up the paisa-exact + // figure, but what physically leaves the drawer is that figure rounded to whole rupees, and + // this total has to agree with a physical count. Only CLOSED rows, dated on disbursed_at, + // since a closure takes nothing out of the drawer until the cashier hands it over (a + // nil-balance closure never reaches a cashier at all, hence the closure_date fallback). + final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(cashier_disbursed_amount), 0) FROM m_clerk_closure_request + WHERE status = 'CLOSED' + AND COALESCE(CAST(disbursed_at AS DATE), closure_date) = ? + """, BigDecimal.class, date)); + + final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals); + // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer From c052beeacf7c945e2da651ba37e25f0e1a9a5935 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 2 Sep 2026 12:07:24 +0530 Subject: [PATCH 27/55] feat(branchday): reject day close if cashiers given cash are not reconciled or settled --- .../api/BranchDayRegisterApiResource.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 249622985..b1bc8ebfd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -494,6 +494,62 @@ public class BranchDayRegisterApiResource { "Business day " + businessDate + " is already closed."); } + // ── Ensure every cashier holding cash has been reconciled AND settled ── + // Find all cashiers under tellers belonging to this office, check if they have a cash position, + // and if so, verify reconciliation and settlement records exist for the business date. + final List> unsettledCashiers = jdbcTemplate.queryForList(""" + SELECT c.id AS cashier_id, c.staff_id, + COALESCE(s.display_name, CONCAT('Cashier #', c.id)) AS cashier_name, + (SELECT COUNT(*) FROM m_cash_reconciliation cr + WHERE cr.cashier_id = c.id AND cr.recon_date = ?) AS recon_count, + (SELECT COUNT(*) FROM m_cash_settlement cs + WHERE cs.from_cashier_id = c.id AND cs.settlement_date = ? + AND (cs.is_reversed IS NULL OR cs.is_reversed = false)) AS settle_count + FROM m_cashiers c + JOIN m_tellers t ON t.id = c.teller_id + LEFT JOIN m_staff s ON s.id = c.staff_id + WHERE t.office_id = ? + AND (c.start_date IS NULL OR c.start_date <= ?) + AND (c.end_date IS NULL OR c.end_date >= ?) + AND ( + EXISTS ( + SELECT 1 FROM m_cash_transfer ct + WHERE ct.to_cashier_id = c.id + AND ct.transfer_date = ? + AND (ct.is_reversed IS NULL OR ct.is_reversed = false) + AND ct.transfer_amount > 0 + ) + OR EXISTS ( + SELECT 1 FROM m_cash_inter_cashier cic + WHERE cic.to_cashier_id = c.id + AND cic.transfer_date = ? + AND (cic.is_reversed IS NULL OR cic.is_reversed = false) + AND cic.transfer_amount > 0 + ) + ) + """, Date.valueOf(businessDate), Date.valueOf(businessDate), + officeId, Date.valueOf(businessDate), Date.valueOf(businessDate), + Date.valueOf(businessDate), Date.valueOf(businessDate)); + + final List blockingCashiers = new ArrayList<>(); + for (final Map row : unsettledCashiers) { + final int reconCount = ((Number) row.get("recon_count")).intValue(); + final int settleCount = ((Number) row.get("settle_count")).intValue(); + if (reconCount == 0 || settleCount == 0) { + final String name = String.valueOf(row.get("cashier_name")); + final List issues = new ArrayList<>(); + if (reconCount == 0) issues.add("Not Reconciled"); + if (settleCount == 0) issues.add("Not Settled"); + blockingCashiers.add(name + " (" + String.join(", ", issues) + ")"); + } + } + if (!blockingCashiers.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", + "Cannot close the day — the following cashiers have not been reconciled or settled: " + + String.join("; ", blockingCashiers) + + ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed."); + } + // Closing a past date is allowed — otherwise a day left open could never be settled at all — // but it must say why it was left open, and it is stamped as a force close. final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); From c86e0d1cc223576556e119b4ce2646dd2d541364 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 2 Sep 2026 17:29:14 +0530 Subject: [PATCH 28/55] fix(savings,closure): post interest for real when a closure is checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticking "Close this account" previewed the payout with a dry run — assemble the account, call the same postInterest domain method close() will, roll the whole transaction back. The clerk asked for the actual thing: post the interest now, the same as "Post Interest As On" always did and the same basis FD uses at maturity, so the figure shown is the account's real balance rather than a simulation of one. Switched to calling SavingsAccountWritePlatformService#postInterest(SavingsAccount, boolean, LocalDate, boolean) directly -- the exact method close() itself calls -- instead of going through the ordinary POSTINTEREST command pipeline. That matters: POSTINTEREST_SAVINGSACCOUNT ships maker-checker-enabled in base Fineract, and a Clerk is not a checker for it. Routed through commandsSourceWritePlatformService, CommandSourceService#processCommand runs the handler first, then evaluates maker-checker and throws RollbackTransactionNotApprovedException -- which, thrown from inside that @Transactional method, rolls back everything the handler just did and drops the request into Fineract's own generic Checker Inbox. This feature already has its own maker-checker (submit here, approve in Closure Requests); the posting now rides inside that instead of opening a second, unrelated approval queue for the same click. Left uncommitted-to on purpose: unticking the box or a later rejection leaves the posting in place, since it is interest the member had genuinely earned either way. Re-ticking re-runs postInterest safely -- the domain layer finds today's posting already there and no-ops rather than posting twice. If manager approval lands on a later business date, close() tops up the extra days on top; it never re-posts what already matches the date it is closing on. Endpoint changed GET -> POST to match: it now mutates. The dry-run machinery (SavingsAccountAssembler kept, but PlatformTransactionManager/TransactionTemplate/ ConfigurationDomainService/MathContext all dropped) is gone with it -- the real service method already handles all of that internally, the same way it does for every other caller of postInterest. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/ClerkClosureRequestApiResource.java | 113 ++++++------------ 1 file changed, 39 insertions(+), 74 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 4d3d6f201..c8f2e453d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; -import java.math.MathContext; import java.math.RoundingMode; import java.sql.Date; import java.sql.Timestamp; @@ -53,13 +52,12 @@ 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.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; @@ -67,10 +65,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; /** * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s @@ -99,10 +94,9 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to compute the closing figure the way close() will actually compute it. + // Used only by preview(), to post interest for real the same way close() eventually will. private final SavingsAccountAssembler savingsAccountAssembler; - private final ConfigurationDomainService configurationDomainService; - private final PlatformTransactionManager transactionManager; + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -175,47 +169,45 @@ public class ClerkClosureRequestApiResource { return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } - // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── + // ── POST /v1/clerk/closure-requests/preview/{savingsAccountId} ───────────── /** - * "What would this member actually receive" preview, so a clerk sees the real closing figure - * before submitting. + * Posts interest to today for real -- the same domain call close() makes + * ({@code postInterest(account, true, businessDate, false)}) -- and reports back the resulting + * balance, so the clerk sees the exact figure the account will close with, already credited. * - * This must reproduce what close() will pay out, to the paisa, and reading the stored - * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last - * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is - * close but not identical -- it uses {@code MathContext(15)} and passes - * {@code postInterestOnDate = null}, whereas close() calls - * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and - * feeds {@code closedDate} in as an extra posting-period boundary - * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period - * changes how many per-period roundings happen, so the two can disagree by paise. + * This is a genuine posting, not an estimate. It has to be: a dry run reproducing close()'s own + * MathContext(10) and postInterestOnDate was tried first, but calling + * {@link org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService + * #postInterest(SavingsAccount, boolean, LocalDate, boolean)} directly -- the exact method + * close() itself calls -- makes the account's own real balance the source of truth instead, and + * removes an entire class of "did the simulation really match reality" risk. * - * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method - * close() ends up in with the same arguments, read the resulting balance -- which is exactly the - * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right - * after posting interest) -- and then roll the whole thing back. + * Called directly on the write-platform service, not through the ordinary POSTINTEREST command + * pipeline: that permission ({@code POSTINTEREST_SAVINGSACCOUNT}) ships maker-checker-enabled in + * base Fineract, and a Clerk is not a checker for it. Routed through + * {@code commandsSourceWritePlatformService} it would silently roll back and land in Fineract's + * own generic Checker Inbox -- a second, unrelated approval queue on top of this one. This + * feature already has its own maker-checker (submit here, approve in Closure Requests), so the + * interest posting rides inside that instead: real the moment the clerk ticks the box, same as + * "Post Interest As On" always was, with the actual approval gate being who signs off on the + * closure itself. * - * Why this is safe to run per keystroke: - *
    - *
  • The domain {@code postInterest} touches nothing outside the entity graph: no repository - * save, no journal entry, no voucher, no event. All of that lives in the service layer, which is - * deliberately bypassed here.
  • - *
  • REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the - * dirtied entities live in a separate persistence context that is discarded.
  • - *
  • Config is read before the account is assembled: a cache-miss JPA query afterwards could - * auto-flush the in-memory interest transactions, and while the rows would roll back, the - * identity-sequence values they consumed would not.
  • - *
+ * Posting now and closing later is safe to leave uncommitted-to: if the clerk unticks the box or + * the request is rejected, the interest already posted is simply real interest the member had + * genuinely earned -- crediting it is correct with or without the closure going through, and + * re-ticking later just finds the same day's posting already there and no-ops (see + * SavingsAccount#postInterest's own correction check). If manager approval lands on a later + * business date, close() posts the extra days on top; it never re-posts what is already there + * for a date that matches. */ - @GET + @POST @Path("/preview/{savingsAccountId}") @Produces(MediaType.APPLICATION_JSON) public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { final List> rows = jdbcTemplate.queryForList(""" SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, - COALESCE(c.display_name, g.display_name) AS client_name, - COALESCE(sa.account_balance_derived, 0) AS balance + COALESCE(c.display_name, g.display_name) AS client_name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id @@ -227,49 +219,23 @@ public class ClerkClosureRequestApiResource { final Map account = rows.get(0); // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / // RecurringDepositAccount, whose interest and tax on closure run through - // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below - // would quietly compute the wrong number for them. + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- calling the plain + // savings postInterest on one of those would post the wrong figure. final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); if (depositType != 100) { throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); } - final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); // The CBS business date, not wall-clock: this is the date the closure will actually post on - // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up + // (executeClose sends it as closedOnDate) and the date close() itself would post interest up // to. Using LocalDate.now() here would silently disagree with approval around a day roll. final LocalDate closureDate = DateUtils.getBusinessLocalDate(); - // Read configuration first -- see the flush note in the javadoc. - final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService - .isSavingsInterestPostingAtCurrentPeriodEnd(); - final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); - final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); - - final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); - dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - - BigDecimal[] figures = null; - try { - figures = dryRun.execute(status -> { - final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); - final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); - // The exact call close() makes, via postInterest(account, true, closedDate, false). - acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, closureDate, false, false); - final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); - status.setRollbackOnly(); - return new BigDecimal[] { before, after }; - }); - } catch (final RuntimeException e) { - // Never cost the clerk the preview entirely -- fall through to the stored balance and - // flag that the interest figure could not be computed. - figures = null; - } - - final boolean computed = figures != null; - final BigDecimal balance = computed ? figures[0] : storedBalance; - final BigDecimal total = computed ? figures[1] : storedBalance; + final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + final BigDecimal balance = zeroIfNull(acct.getSummary().getAccountBalance()); + // The exact call close() makes: postInterest(account, true, closedDate, false). + savingsAccountWritePlatformService.postInterest(acct, true, closureDate, false); + final BigDecimal total = zeroIfNull(acct.getSummary().getAccountBalance()); final BigDecimal interest = total.subtract(balance); final JsonObject res = new JsonObject(); @@ -283,7 +249,6 @@ public class ClerkClosureRequestApiResource { // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); - res.addProperty("interestUpToDate", computed); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); } From 45c2826a4256618c9a87900888f491314e9f9c83 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 2 Sep 2026 18:31:45 +0530 Subject: [PATCH 29/55] Revert "fix(savings,closure): post interest for real when a closure is checked" This reverts commit c86e0d1cc223576556e119b4ce2646dd2d541364. --- .../api/ClerkClosureRequestApiResource.java | 113 ++++++++++++------ 1 file changed, 74 insertions(+), 39 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index c8f2e453d..4d3d6f201 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; +import java.math.MathContext; import java.math.RoundingMode; import java.sql.Date; import java.sql.Timestamp; @@ -52,12 +53,13 @@ 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.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; @@ -65,7 +67,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; /** * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s @@ -94,9 +99,10 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to post interest for real the same way close() eventually will. + // Used only by preview(), to compute the closing figure the way close() will actually compute it. private final SavingsAccountAssembler savingsAccountAssembler; - private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -169,45 +175,47 @@ public class ClerkClosureRequestApiResource { return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } - // ── POST /v1/clerk/closure-requests/preview/{savingsAccountId} ───────────── + // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── /** - * Posts interest to today for real -- the same domain call close() makes - * ({@code postInterest(account, true, businessDate, false)}) -- and reports back the resulting - * balance, so the clerk sees the exact figure the account will close with, already credited. + * "What would this member actually receive" preview, so a clerk sees the real closing figure + * before submitting. * - * This is a genuine posting, not an estimate. It has to be: a dry run reproducing close()'s own - * MathContext(10) and postInterestOnDate was tried first, but calling - * {@link org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService - * #postInterest(SavingsAccount, boolean, LocalDate, boolean)} directly -- the exact method - * close() itself calls -- makes the account's own real balance the source of truth instead, and - * removes an entire class of "did the simulation really match reality" risk. + * This must reproduce what close() will pay out, to the paisa, and reading the stored + * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last + * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is + * close but not identical -- it uses {@code MathContext(15)} and passes + * {@code postInterestOnDate = null}, whereas close() calls + * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and + * feeds {@code closedDate} in as an extra posting-period boundary + * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period + * changes how many per-period roundings happen, so the two can disagree by paise. * - * Called directly on the write-platform service, not through the ordinary POSTINTEREST command - * pipeline: that permission ({@code POSTINTEREST_SAVINGSACCOUNT}) ships maker-checker-enabled in - * base Fineract, and a Clerk is not a checker for it. Routed through - * {@code commandsSourceWritePlatformService} it would silently roll back and land in Fineract's - * own generic Checker Inbox -- a second, unrelated approval queue on top of this one. This - * feature already has its own maker-checker (submit here, approve in Closure Requests), so the - * interest posting rides inside that instead: real the moment the clerk ticks the box, same as - * "Post Interest As On" always was, with the actual approval gate being who signs off on the - * closure itself. + * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method + * close() ends up in with the same arguments, read the resulting balance -- which is exactly the + * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right + * after posting interest) -- and then roll the whole thing back. * - * Posting now and closing later is safe to leave uncommitted-to: if the clerk unticks the box or - * the request is rejected, the interest already posted is simply real interest the member had - * genuinely earned -- crediting it is correct with or without the closure going through, and - * re-ticking later just finds the same day's posting already there and no-ops (see - * SavingsAccount#postInterest's own correction check). If manager approval lands on a later - * business date, close() posts the extra days on top; it never re-posts what is already there - * for a date that matches. + * Why this is safe to run per keystroke: + *
    + *
  • The domain {@code postInterest} touches nothing outside the entity graph: no repository + * save, no journal entry, no voucher, no event. All of that lives in the service layer, which is + * deliberately bypassed here.
  • + *
  • REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the + * dirtied entities live in a separate persistence context that is discarded.
  • + *
  • Config is read before the account is assembled: a cache-miss JPA query afterwards could + * auto-flush the in-memory interest transactions, and while the rows would roll back, the + * identity-sequence values they consumed would not.
  • + *
*/ - @POST + @GET @Path("/preview/{savingsAccountId}") @Produces(MediaType.APPLICATION_JSON) public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { final List> rows = jdbcTemplate.queryForList(""" SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, - COALESCE(c.display_name, g.display_name) AS client_name + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(sa.account_balance_derived, 0) AS balance FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id @@ -219,23 +227,49 @@ public class ClerkClosureRequestApiResource { final Map account = rows.get(0); // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / // RecurringDepositAccount, whose interest and tax on closure run through - // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- calling the plain - // savings postInterest on one of those would post the wrong figure. + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below + // would quietly compute the wrong number for them. final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); if (depositType != 100) { throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); } + final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); // The CBS business date, not wall-clock: this is the date the closure will actually post on - // (executeClose sends it as closedOnDate) and the date close() itself would post interest up + // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up // to. Using LocalDate.now() here would silently disagree with approval around a day roll. final LocalDate closureDate = DateUtils.getBusinessLocalDate(); - final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); - final BigDecimal balance = zeroIfNull(acct.getSummary().getAccountBalance()); - // The exact call close() makes: postInterest(account, true, closedDate, false). - savingsAccountWritePlatformService.postInterest(acct, true, closureDate, false); - final BigDecimal total = zeroIfNull(acct.getSummary().getAccountBalance()); + // Read configuration first -- see the flush note in the javadoc. + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); + + final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); + dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + BigDecimal[] figures = null; + try { + figures = dryRun.execute(status -> { + final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); + // The exact call close() makes, via postInterest(account, true, closedDate, false). + acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, closureDate, false, false); + final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); + status.setRollbackOnly(); + return new BigDecimal[] { before, after }; + }); + } catch (final RuntimeException e) { + // Never cost the clerk the preview entirely -- fall through to the stored balance and + // flag that the interest figure could not be computed. + figures = null; + } + + final boolean computed = figures != null; + final BigDecimal balance = computed ? figures[0] : storedBalance; + final BigDecimal total = computed ? figures[1] : storedBalance; final BigDecimal interest = total.subtract(balance); final JsonObject res = new JsonObject(); @@ -249,6 +283,7 @@ public class ClerkClosureRequestApiResource { // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("interestUpToDate", computed); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); } From f6686b0c8f644c139c25fc5d4ec691f1dca8deb7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 3 Sep 2026 10:41:14 +0530 Subject: [PATCH 30/55] fix(savings): stop manual interest postings from double-counting across runs SavingsAccount#postInterest silently double-credited interest whenever a manual posting ("Post Interest As On", or close() closing an account) ran on two different dates against the same account. Root cause: postInterestOnDate=D makes SavingsHelper#determineInterestPostingPeriods close a period at D-1, so the resulting transaction is dated D-1, not D. The next run's getManualPostingDates() feeds that D-1 date back in as a boundary, which closes a period at D-2 instead. The old posting's date (D-1) then matches no period end in the new run, so findInterestPostingTransactionFor() never finds it, it is never reversed or corrected, and a new posting is created alongside it covering the very same days. total_interest_posted_derived drifts above what was actually earned by exactly the overlapping amount. Reproduced live: posting "as on" 02 Sep then 03 Sep on the same account left a stray 0.35 posting dated 01 Sep plus a new 0.97 posting for 01-02 Sep -- 1.32 posted against 0.97 actually earned. Fix: before applying this run's periods, reverse any manual posting whose date matches no period-posting-date this run computed AND whose days are covered by a period this run WILL post (posting date <= interestPostingUpToDate). The second condition is deliberate -- it is what stops this from reversing a posting whose days are simply not being re-posted yet (e.g. the recalculation Fineract runs right after a withdrawal, where the remaining period matures in the future and posts nothing for those days). Automatic (non-manual) postings are untouched; they sit on natural period ends, which always recur correctly. Affects every caller of the base postInterest/calculateInterestUsing pair, including FixedDepositAccount and RecurringDepositAccount (both delegate to super.postInterest for their day-to-day interest recalculation, though not for postMaturityInterest, which is a separate, untouched method) -- so this also closes the same double-count window for FD/RD accounts, not just plain savings. --- .../savings/domain/SavingsAccount.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 3eb427dcf..039ca6d90 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -595,6 +595,62 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom withholdTransactions.addAll(findWithHoldTransactions()); } + // Reverse manual interest postings that no longer line up with any period this run computed, + // BEFORE the periods below are applied. + // + // A user posting ("post interest as on D") lands its transaction on D-1, because + // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in + // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 + // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the + // posting's date matches no period end the second time around: findInterestPostingTransactionFor + // below misses it, it is never reversed or corrected, and it survives alongside a new posting + // covering the very same days. The member is credited twice, and total_interest_posted_derived + // drifts above what was actually earned. + // + // A posting is only treated as orphaned, and only reversed, when BOTH hold: + // - its date is not any period's posting date, so the loop below will never find and correct it; + // - some period that WILL be posted in this run (posting date on or before + // interestPostingUpToDate) covers that date, so the days it credited are being credited again. + // The second condition is what keeps this from ever destroying interest. A run whose remaining + // period only matures in the future posts nothing for those days -- the recalculation that + // follows a withdrawal is exactly that case -- and reversing there would silently take earned + // interest back off the member instead of de-duplicating it. + // Automatic postings are left alone: they sit on natural period ends, which always recur. + final Set periodPostingDates = new HashSet<>(); + final List repostedIntervals = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + periodPostingDates.add(period.dateOfPostingTransaction()); + if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { + repostedIntervals.add(period.getPeriodInterval()); + } + } + final List postingsInScope = backdatedTxnsAllowedTill + ? getSavingsAccountTransactionsWithPivotConfig() + : getTransactions(); + for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { + final LocalDate transactionDate = transaction.getTransactionDate(); + if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() + || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { + continue; + } + boolean coveredByAPostedPeriod = false; + for (final LocalDateInterval interval : repostedIntervals) { + if (interval.contains(transactionDate)) { + coveredByAPostedPeriod = true; + break; + } + } + if (!coveredByAPostedPeriod) { + continue; + } + transaction.reverse(); + final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); + if (strandedWithholdTransaction != null) { + strandedWithholdTransaction.reverse(); + } + recalucateDailyBalanceDetails = true; + } + for (final PostingPeriod interestPostingPeriod : postingPeriods) { final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); From a04b65583e7ddae88db9aa4f823eebd60f00034e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 3 Sep 2026 10:41:38 +0530 Subject: [PATCH 31/55] fix(closure): post interest for real at submission, not as a dry-run estimate Ticking "Close this account" used to only preview the payout: assemble the account, run the same postInterest domain call close() will, read the resulting balance, then roll the whole thing back (GET /preview/{id}). Nothing was ever credited until the manager approved, and the account's own Interest Posted / Interest Earned figures stayed stale in the meantime. Changed to actually post the interest at that moment (POST /post-interest/{id} and again in submit()), via SavingsAccountWritePlatformService#postInterest(SavingsAccount, boolean, LocalDate, boolean) -- the exact service method close() itself calls, not a new calculation. Not routed through the POSTINTEREST_SAVINGSACCOUNT command: that command ships maker-checker-enabled in base Fineract, and CommandSourceService#processCommand runs the handler first, then throws RollbackTransactionNotApprovedException from inside the @Transactional method -- which would silently roll back the very posting just made. Depends on the sibling SavingsAccount fix (double-posting across runs) -- without it, ticking the checkbox on one business day and having the manager approve on a later one re-slices posting periods and credits the overlapping days twice. Also: - Locks the closure date at submission (stored on the request row) and replays that same date at approve() instead of re-reading today's business date, so close()'s own postInterest(account, true, closedOnDate, false) slices identical periods, matches the amounts already posted at submission, and adds nothing -- the figure quoted to the member at submission is what gets paid out however long the request sits in the manager's queue. - Populates closing_amount/rounding_amount/cashier_disbursed_amount at submission time instead of leaving them null until approval, since the interest posting above already makes the real payout figure known then -- the manager's review screen no longer shows a blank amount for a PENDING request. --- .../api/ClerkClosureRequestApiResource.java | 218 ++++++++++-------- 1 file changed, 126 insertions(+), 92 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 4d3d6f201..5569a5834 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; -import java.math.MathContext; import java.math.RoundingMode; import java.sql.Date; import java.sql.Timestamp; @@ -45,6 +44,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -53,13 +53,12 @@ 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.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; @@ -67,10 +66,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionTemplate; /** * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s @@ -99,11 +95,15 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to compute the closing figure the way close() will actually compute it. + // Used by postClosureInterest(), invoked from both submit() and postInterestForClosure(), to + // assemble the account before posting real interest onto it. private final SavingsAccountAssembler savingsAccountAssembler; - private final ConfigurationDomainService configurationDomainService; - private final PlatformTransactionManager transactionManager; private final VoucherService voucherService; + // The interest posting that has always backed "Post Interest As On" and close() alike. Called as + // the service method rather than through the POSTINTEREST_SAVINGSACCOUNT command, whose + // maker-checker flag would roll the posting straight back out again (CommandSourceService runs + // the handler first, then throws RollbackTransactionNotApprovedException). + private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── POST /v1/clerk/closure-requests ── Clerk submits new request ──────────── @@ -154,64 +154,97 @@ public class ClerkClosureRequestApiResource { throw apiError(409, "This account already has an open closure request (pending or awaiting cashier)."); } + // The closure date is fixed HERE, at submission, and every later step reads it back off the row + // rather than re-reading the business date. That is what keeps the figure the clerk quoted the + // member identical to the figure the cashier hands over: closedOnDate drives which posting + // periods close() slices, so letting it float to the manager's approval day would recompute the + // interest against different period boundaries and pay out a different amount. + final LocalDate closureDate = DateUtils.getBusinessLocalDate(); + + // Post the interest for real, now -- the account's balance must already include everything the + // member has earned the moment the clerk raises the closure, not merely be estimated. This is + // the same service method "Post Interest As On" and close() both go through, invoked with the + // same arguments close() will use (postInterestAs = true, transactionDate = closureDate), so + // when the manager approves, close()'s own postInterest(account, true, closureDate, false) finds + // these very transactions at the very same period ends, matches their amounts, and adds nothing. + final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate); + // Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING, + // exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure. + final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount); + final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_clerk_closure_request (savings_account_id, account_no, client_id, client_name, closure_date, payment_type_id, payment_type_name, description, tok_no, - status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, closing_amount, rounding_amount, cashier_disbursed_amount, + clerk_id, clerk_name, clerk_username, activity_log, submitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, savingsAccountId, account.get("account_no"), account.get("client_id"), account.get("client_name"), - Date.valueOf(DateUtils.getBusinessLocalDate()), + Date.valueOf(closureDate), paymentTypeId, paymentTypeName, description, tokNo, - "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + "PENDING", closingAmount, roundingAmount, cashierDisbursedAmount, + currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()) ); - return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + final Map response = new LinkedHashMap<>(); + response.put("resourceId", newId); + response.put("status", "PENDING"); + response.put("closureDate", closureDate.toString()); + response.put("payableAmount", closingAmount); + response.put("roundedPayableAmount", cashierDisbursedAmount); + return gson.toJson(response); } - // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── + /** + * Posts everything the member has earned up to the closure date, and returns the resulting real + * account balance -- the figure the closure will pay out. + * + * Deliberately not the CALCULATEINTEREST command and not a bespoke calculation: it is the very + * {@code postInterest(account, true, closureDate, false)} call that + * {@code SavingsAccountWritePlatformServiceJpaRepositoryImpl#close} makes, so the two agree by + * construction rather than by coincidence. Going through the service (not the entity directly) + * keeps the journal entries and the interest voucher that a posting is supposed to raise. + */ + private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) { + final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + savingsAccountWritePlatformService.postInterest(account, true, closureDate, false); + return zeroIfNull(account.getSummary().getAccountBalance()); + } + + // ── POST /v1/clerk/closure-requests/post-interest/{savingsAccountId} ─────── /** - * "What would this member actually receive" preview, so a clerk sees the real closing figure - * before submitting. + * Posts everything the member has earned up to the closure date and reports the account's real + * resulting balance -- the figure the closure will pay out. * - * This must reproduce what close() will pay out, to the paisa, and reading the stored - * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last - * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is - * close but not identical -- it uses {@code MathContext(15)} and passes - * {@code postInterestOnDate = null}, whereas close() calls - * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and - * feeds {@code closedDate} in as an extra posting-period boundary - * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period - * changes how many per-period roundings happen, so the two can disagree by paise. + * A POST, not a GET, because it genuinely mutates: ticking "Close this account" is meant to put + * the interest INTO the account, not to estimate it. A dry run used to stand here, and it read + * correctly to the paisa, but it left the account's own balance behind the figure on screen and + * the clerk had no way to see that the member had actually been credited. * - * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method - * close() ends up in with the same arguments, read the resulting balance -- which is exactly the - * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right - * after posting interest) -- and then roll the whole thing back. + * The posting itself is {@link #postClosureInterest}, i.e. the very + * {@code postInterest(account, true, closureDate, false)} that + * {@code SavingsAccountWritePlatformServiceJpaRepositoryImpl#close} performs -- so approval later + * finds these transactions at the identical period ends and adds nothing. * - * Why this is safe to run per keystroke: - *
    - *
  • The domain {@code postInterest} touches nothing outside the entity graph: no repository - * save, no journal entry, no voucher, no event. All of that lives in the service layer, which is - * deliberately bypassed here.
  • - *
  • REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the - * dirtied entities live in a separate persistence context that is discarded.
  • - *
  • Config is read before the account is assembled: a cache-miss JPA query afterwards could - * auto-flush the in-memory interest transactions, and while the rows would roll back, the - * identity-sequence values they consumed would not.
  • - *
+ * Safe to tick and untick, and safe across days: SavingsAccount#postInterest reverses any manual + * posting this run's periods have orphaned before applying them, so a second run can no longer + * double-credit the days an earlier one already paid. Cancelling the popup or a manager rejecting + * the request deliberately leaves the posting in place -- it is interest the member had genuinely + * earned either way, and the next posting run reconciles it correctly. */ - @GET - @Path("/preview/{savingsAccountId}") + @POST + @Path("/post-interest/{savingsAccountId}") @Produces(MediaType.APPLICATION_JSON) - public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { + @Transactional + public String postInterestForClosure(@PathParam("savingsAccountId") final Long savingsAccountId) { final List> rows = jdbcTemplate.queryForList(""" SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, COALESCE(c.display_name, g.display_name) AS client_name, @@ -227,63 +260,39 @@ public class ClerkClosureRequestApiResource { final Map account = rows.get(0); // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / // RecurringDepositAccount, whose interest and tax on closure run through - // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below - // would quietly compute the wrong number for them. + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- posting through the + // plain savings path would quietly compute the wrong number for them. final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); if (depositType != 100) { throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); } - - final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); - // The CBS business date, not wall-clock: this is the date the closure will actually post on - // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up - // to. Using LocalDate.now() here would silently disagree with approval around a day roll. - final LocalDate closureDate = DateUtils.getBusinessLocalDate(); - - // Read configuration first -- see the flush note in the javadoc. - final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService - .isSavingsInterestPostingAtCurrentPeriodEnd(); - final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); - final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); - - final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); - dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - - BigDecimal[] figures = null; - try { - figures = dryRun.execute(status -> { - final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); - final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); - // The exact call close() makes, via postInterest(account, true, closedDate, false). - acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, closureDate, false, false); - final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); - status.setRollbackOnly(); - return new BigDecimal[] { before, after }; - }); - } catch (final RuntimeException e) { - // Never cost the clerk the preview entirely -- fall through to the stored balance and - // flag that the interest figure could not be computed. - figures = null; + final int statusEnum = ((Number) account.get("status_enum")).intValue(); + if (statusEnum != 300) { + throw apiError(409, "Only an ACTIVE account can be closed. Current status: " + statusEnum); } - final boolean computed = figures != null; - final BigDecimal balance = computed ? figures[0] : storedBalance; - final BigDecimal total = computed ? figures[1] : storedBalance; - final BigDecimal interest = total.subtract(balance); + final BigDecimal balanceBefore = new BigDecimal(account.get("balance").toString()); + // The CBS business date, not wall-clock: submit() stamps this same date onto the request as + // closure_date, and approve() replays it as closedOnDate -- so it is the date the interest is + // calculated up to at every step. Using LocalDate.now() here would silently disagree around a + // day roll. + final LocalDate closureDate = DateUtils.getBusinessLocalDate(); + + final BigDecimal total = postClosureInterest(savingsAccountId, closureDate); + final BigDecimal interest = total.subtract(balanceBefore); final JsonObject res = new JsonObject(); res.addProperty("savingsAccountId", savingsAccountId); res.addProperty("accountNo", String.valueOf(account.get("account_no"))); res.addProperty("clientName", account.get("client_name") == null ? "" : String.valueOf(account.get("client_name"))); - res.addProperty("statusEnum", ((Number) account.get("status_enum")).intValue()); - res.addProperty("balance", balance); + res.addProperty("statusEnum", statusEnum); + res.addProperty("balance", balanceBefore); res.addProperty("accruedInterest", interest); res.addProperty("total", total); // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); - res.addProperty("interestUpToDate", computed); + res.addProperty("interestUpToDate", true); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); } @@ -475,9 +484,14 @@ public class ClerkClosureRequestApiResource { throw apiError(409, "Account is no longer ACTIVE (status " + statusEnum + ") — cannot close."); } - // Fineract's own close command: pro-rata interest, TDS and the real GL withdrawal all post - // atomically here -- exactly as they would from the ordinary Close Savings Account screen. - executeClose(savingsAccountId); + // Fineract's own close command, run against the date the closure was RAISED on, not today's. + // submit() already posted the interest up to that date; replaying the same closedOnDate here + // makes close()'s postInterest(account, true, closedOnDate, false) slice the identical posting + // periods, find those very transactions at the identical period ends, match their amounts and + // add nothing -- so the balance it withdraws is exactly the figure the clerk quoted, however + // many days the request sat in the manager's queue. + final LocalDate closureDate = toLocalDate(record.get("closure_date")); + executeClose(savingsAccountId, closureDate); // Read back the withdrawal transaction close() just posted -- its CommandProcessingResult // doesn't return the transaction id/amount directly. transaction_type_enum 2 = WITHDRAWAL. @@ -672,14 +686,34 @@ public class ClerkClosureRequestApiResource { // ── Helpers ─────────────────────────────────────────────────────────────── - private void executeClose(final Long savingsAccountId) { - // Business date, matching what preview() computed the figure against. - final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + /** + * Reads a DATE column back out of a JDBC result map. The driver hands back {@link java.sql.Date} + * today, but a {@link LocalDate} under a different driver or mapper configuration would otherwise + * blow up the whole approval on a ClassCastException, so both are accepted. + */ + private LocalDate toLocalDate(final Object value) { + if (value == null) { + throw apiError(500, "Closure request is missing its closure date — cannot close the account."); + } + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date sqlDate) { + return sqlDate.toLocalDate(); + } + return LocalDate.parse(value.toString().substring(0, 10)); + } + + private void executeClose(final Long savingsAccountId, final LocalDate closureDate) { + // The date the closure was RAISED on, read off the request row -- never today's business date. + // See the call site: it is what keeps the amount quoted to the member and the amount paid out + // identical no matter how long approval takes. + final String closedOn = closureDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final JsonObject payload = new JsonObject(); payload.addProperty("locale", "en"); payload.addProperty("dateFormat", "dd MMMM yyyy"); - payload.addProperty("closedOnDate", today); + payload.addProperty("closedOnDate", closedOn); // Unconditional and never skippable -- closure always withdraws the full balance. payload.addProperty("withdrawBalance", true); From e4cc2e558efac974bb491c44b2bc2f2fe34cff51 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 3 Sep 2026 18:45:36 +0530 Subject: [PATCH 32/55] cash-only registers, day book sections and DD amendments Cashier reports now reconcile against the drawer: - Received/Payment exclude what cannot be cash rather than admitting only proven cash, so registers that pay out without recording a denomination breakdown are no longer silently dropped - Closing report scoped to the cashier, with opening/closing balance, reversed counter transactions excluded, and cash DD receipts counted - Day book split into Cashier and Clerk sections, filed by whose cash moved rather than who raised the request Fixed one deposit or withdrawal raising two vouchers: the cashier deposit and clerk withdrawal registers did not suppress the inner savings command's voucher, doubling the day's cash on both reports. Sponsor DD: - Amendment request flow (beneficiary, payable-at branch and city) under maker-checker; face value and funding stay unamendable - Cash DD denominations were collected but never saved, and the amount check demanded a figure notes could not add to; both fixed and the breakdown is now required Co-Authored-By: Claude Opus 5 (1M context) --- cleanup_duplicate_vouchers.sql | 68 + ...se_migration_facility_charge_auto_seed.sql | 9 - database_migration_facility_charges.sql | 4 - ...AccountMappingFromApiJsonDeserializer.java | 45 +- .../infrastructure/jobs/service/JobName.java | 2 + .../portfolio/client/domain/ClientKyc.java | 39 + .../client/domain/ClientKycRepository.java | 13 + .../client/domain/KycExpiryStage.java | 106 + .../savings/SavingsApiConstants.java | 1 - .../domain/CurrentAccountFacilityConfig.java | 4 - .../domain/CurrentAccountPendingEdit.java | 3 - .../domain/CurrentProductAssembler.java | 1 - .../domain/CurrentProductFacilityConfig.java | 6 - ...CurrentProductReadPlatformServiceImpl.java | 1 - .../ClientRiskEscalationServiceImpl.java | 4 + .../api/CashierDepositRequestApiResource.java | 14 +- .../cashreport/api/CashReportApiResource.java | 202 +- .../api/CashSettlementApiResource.java | 5 + .../ClerkWithdrawalRequestApiResource.java | 14 +- .../api/ClientKycExceptionsApiResource.java | 19 + .../ApplyKycExpiryRestrictionsConfig.java | 52 + .../ApplyKycExpiryRestrictionsTasklet.java | 46 + .../service/ClientKycReadPlatformService.java | 10 + .../ClientKycReadPlatformServiceImpl.java | 9 + .../service/KycExpiryRestrictionService.java | 49 + .../KycExpiryRestrictionServiceImpl.java | 134 + .../api/DeafRemittanceApiResource.java | 80 + .../api/DormantAccountsApiResource.java | 59 + .../SavingsDailyInterestOrchestrator.java | 62 + .../SavingsDailyInterestService.java | 142 +- .../SavingsPooledInterestPostingService.java | 105 +- .../SavingsProductTermsCascadeService.java | 194 + .../api/SavingsDailyInterestApiResource.java | 141 + .../jobs/SavingsDailyInterestConfig.java | 52 + .../jobs/SavingsDailyInterestTasklet.java | 52 + .../savings/data/DormantAccountData.java | 75 + ...WritePlatformServiceJpaRepositoryImpl.java | 6 - ...CurrentAccountReadPlatformServiceImpl.java | 1 - ...urrentAccountWritePlatformServiceImpl.java | 1 - .../DeafRemittanceWritePlatformService.java | 40 + ...eafRemittanceWritePlatformServiceImpl.java | 136 + .../DormantAccountReadPlatformService.java | 37 + ...DormantAccountReadPlatformServiceImpl.java | 151 + ...WritePlatformServiceJpaRepositoryImpl.java | 5 - ...SavingsAccountReadPlatformServiceImpl.java | 3246 +++--- ...WritePlatformServiceJpaRepositoryImpl.java | 15 + ...SavingsProductReadPlatformServiceImpl.java | 1 - ...WritePlatformServiceJpaRepositoryImpl.java | 15 + .../savings/starter/SavingsConfiguration.java | 11 +- .../sponsordd/api/SponsorDDApiResource.java | 75 +- .../data/SponsorDDAmendmentRequestData.java | 62 + .../data/SponsorDDGlMappingData.java | 6 + .../sponsordd/data/SponsorDDLeafletData.java | 2 + ...DAmendmentRequestWritePlatformService.java | 45 + ...ndmentRequestWritePlatformServiceImpl.java | 217 + ...sorDDIssueRequestWritePlatformService.java | 2 +- ...DIssueRequestWritePlatformServiceImpl.java | 260 +- .../service/SponsorDDReadPlatformService.java | 6 + .../SponsorDDReadPlatformServiceImpl.java | 117 +- .../api/VoucherVerificationApiResource.java | 83 +- .../db/changelog/tenant/changelog-tenant.xml | 19 +- ...eate_savings_interest_posting_schedule.xml | 5 + .../0520_add_kyc_expiry_restrictions_job.xml | 52 + .../parts/0521_rbi_dormancy_defaults.xml | 72 + .../parts/0522_create_deaf_remittance.xml | 77 + .../parts/0523_dbt_dormancy_exemption.xml | 57 + ...0524_rbi_dormancy_periods_all_products.xml | 77 + .../parts/0525_savings_daily_interest_job.xml | 74 + .../0526_savings_interest_summary_view.xml | 97 + ...ngs_daily_interest_one_row_per_account.xml | 92 + .../0528_fix_escheat_liability_mapping.xml | 51 + ...ation_data_to_sponsor_dd_issue_request.xml | 38 + .../0530_drop_dbt_dormancy_exemption.xml | 45 + ...531_disable_stock_savings_interest_job.xml | 47 + .../0532_sponsor_dd_amendment_request.xml | 82 + ...SavingsProductAccountingDataValidator.java | 16 +- .../savings/domain/DeafRemittance.java | 158 + .../domain/DeafRemittanceRepository.java | 32 + .../domain/FacilityConfigEditFields.java | 3 - .../domain/ProductPendingEditFieldMapper.java | 2 - .../savings/domain/SavingsAccount.java | 9302 +++++++++-------- .../domain/SavingsAccountFacilityConfig.java | 7 - .../domain/SavingsAccountPendingEdit.java | 3 - .../domain/SavingsProductAssembler.java | 2 - .../domain/SavingsProductFacilityConfig.java | 7 - .../FacilityChargeAutoApplyService.java | 2 - .../service/FacilityChargeSeederService.java | 103 +- .../savings/parts/module-changelog-master.xml | 115 +- ...ngs_product_classification_code_values.xml | 17 + .../parts/2032_remove_withdrawal_facility.xml | 101 + 90 files changed, 10606 insertions(+), 6511 deletions(-) create mode 100644 cleanup_duplicate_vouchers.sql create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml diff --git a/cleanup_duplicate_vouchers.sql b/cleanup_duplicate_vouchers.sql new file mode 100644 index 000000000..a5c7d2a23 --- /dev/null +++ b/cleanup_duplicate_vouchers.sql @@ -0,0 +1,68 @@ +-- Removes the duplicate vouchers left behind by the double-count bug. +-- +-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner +-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two +-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_ +-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received +-- and Payment reports counted both, doubling the day's cash. +-- +-- The code is fixed, so no new duplicates are created. This clears the ones already recorded. +-- +-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper -- +-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto +-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break +-- that link and orphan the request row. +-- +-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql + +BEGIN; + +-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a +-- register voucher. Matching through savings_txn_id rather than by date or amount so that two +-- genuinely separate transactions of the same value on the same day are never confused. +CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS +SELECT sav.voucher_no + FROM m_voucher sav + JOIN m_voucher reg + ON reg.savings_txn_id = sav.reference_id + AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST') + WHERE sav.reference_type = 'SAVINGS_TRANSACTION' + AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL'); + +-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a +-- verification or an attachment means someone acted on that slip, and it must be looked at by hand +-- rather than removed silently. +DO $$ +DECLARE + blocked INT; +BEGIN + SELECT COUNT(*) INTO blocked + FROM (SELECT voucher_no FROM m_voucher_verification + WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers) + UNION ALL + SELECT voucher_no FROM m_voucher_attachment + WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x; + IF blocked > 0 THEN + RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked; + END IF; +END $$; + +-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should +-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at. +DO $$ +DECLARE + stamped INT; +BEGIN + SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction + WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers); + IF stamped > 0 THEN + RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped; + END IF; +END $$; + +SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no; + +DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers); +DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers); + +COMMIT; diff --git a/database_migration_facility_charge_auto_seed.sql b/database_migration_facility_charge_auto_seed.sql index c8114bed8..d05d3b39e 100644 --- a/database_migration_facility_charge_auto_seed.sql +++ b/database_migration_facility_charge_auto_seed.sql @@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0, (SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1) WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0); -INSERT IGNORE INTO m_charge - (name, currency_code, amount, charge_applies_to_enum, charge_time_enum, - charge_calculation_enum, is_penalty, is_active, is_deleted, - is_free_withdrawal, is_payment_type, income_or_liability_account_id) -SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0, - (SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1) -WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0); - INSERT IGNORE INTO m_charge (name, currency_code, amount, charge_applies_to_enum, charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, @@ -168,6 +160,5 @@ VALUES ('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)), ('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)), ('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)), - ('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)), ('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)), ('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1)); diff --git a/database_migration_facility_charges.sql b/database_migration_facility_charges.sql index 6a833c86d..2de849402 100644 --- a/database_migration_facility_charges.sql +++ b/database_migration_facility_charges.sql @@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, - ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; @@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), - ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); @@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, - ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; @@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), - ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java index 511ce5e80..fff2ccc74 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java @@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer { baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId) .notNull().integerGreaterThanZero(); - final Long transfersInSuspenseAccountId = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) - .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); + // Transfers In Suspense is not offered by every savings product UI, so it is validated only + // when the client actually sends it. Omitting it leaves the mapping unset rather than failing. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) { + final Long transfersInSuspenseAccountId = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) + .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); + } final Long interestOnSavingsAccountId = this.fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); @@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer { } if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) { - final Long overdraftAccount = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) - .value(overdraftAccount).notNull().integerGreaterThanZero(); + // Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products + // only, so they are validated when supplied rather than demanded from every savings product. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), + element)) { + final Long overdraftAccount = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) + .value(overdraftAccount).notNull().integerGreaterThanZero(); + } - final Long incomeFromInterest = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest) - .notNull().integerGreaterThanZero(); + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) { + final Long incomeFromInterest = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()) + .value(incomeFromInterest).notNull().integerGreaterThanZero(); + } - final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), - element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull() - .integerGreaterThanZero(); + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) { + final Long writtenOff = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff) + .notNull().integerGreaterThanZero(); + } } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index aca7da9a6..f7aabdc4d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -41,6 +41,8 @@ public enum JobName { FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // + APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // + SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 0b2b05eef..1c02f0dc2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { actualChanges.put(riskParam, newValue); recordOverwrite(riskParam, this.riskCategory); this.riskCategory = newValue; + + // The re-KYC cycle is a property of the risk category, so moving the category moves the + // due date with it — otherwise a customer raised to High keeps a Low customer's ten-year + // renewal and is never called back in for the two-yearly review the rating requires. + final LocalDate newExpiry = recalculateExpiryForRiskCategory(); + if (newExpiry != null) { + actualChanges.put("expiryDate", newExpiry); + } } // remarks is the audit justification itself, so a change to it is deliberately not recorded @@ -677,6 +685,34 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { } } + /** + * Re-dates the re-KYC due date after the risk category moves. + * + * The cycle length belongs to the category the customer is in now (Low 10, Medium 8, + * High 2), and it is counted from the day of the change rather than from the original + * verification. A customer moved to High in year two of a Low rating is then due in two years' + * time, not immediately — dating it from {@code verifiedOn} would push most of the book into + * an overdue queue the moment a re-rating ran. + * + *

Only a verified KYC carries an expiry: an unverified record has nothing to renew, and + * {@link #markVerified} sets the date when it is first verified. + * + * @return the new expiry date, or {@code null} if the record carries no expiry + */ + private LocalDate recalculateExpiryForRiskCategory() { + if (this.verifiedOn == null) { + return null; + } + + final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory); + if (risk.getReKycYears() <= 0) { + return this.expiryDate; + } + + this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears()); + return this.expiryDate; + } + /** Called when verification fails. */ public void markRejected(final String remarks) { this.kycStatus = ClientKycStatus.REJECTED.getValue(); @@ -798,6 +834,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { */ public void escalateRiskCategory(Integer newCategory) { this.riskCategory = newCategory; + // An auto-escalation moves the rating for the same reason an officer would, so it moves the + // re-KYC due date on the same terms — see recalculateExpiryForRiskCategory(). + recalculateExpiryForRiskCategory(); } public String getPanNumber() { diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java index e7f1f9eee..5b506f6d7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.client.domain; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository { @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status") List findAllByStatus(@Param("status") Integer status); + /** + * KYC records whose periodic re-KYC due date has passed, oldest first. + * + *

Only records that carry an expiry are considered: an expiry is set when the KYC is first + * verified, so one without a date has never been verified and has no renewal cycle to lapse. + * A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that + * never took effect, and restricting an account over it would punish the wrong thing. + */ + @Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn " + + "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC") + List findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus); + // ── Pre-KYC uniqueness probes ─────────────────────────────────────────── // Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the // external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java new file mode 100644 index 000000000..9e98fd63e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +/** + * How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed. + * + *

+ * RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out. + * The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be + * closed merely because periodic KYC is pending, and that any restriction follows notice and a fair + * opportunity to comply. The ladder therefore leaves the account untouched for a grace period, + * stops debits before it stops anything else, and never reaches closure on its own — closure stays + * a manual decision with its own notice. + * + *

+ * The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate + * notice" without fixing a number — so they are declared here once and read from here everywhere. + */ +public enum KycExpiryStage { + + /** + * Inside the grace window. Nothing is restricted; the customer is being chased. + */ + GRACE(0), + + /** + * Debits stopped, credits still accepted. The first actual restriction, and the one that brings + * the customer into the branch without putting their incoming money at risk. + */ + PARTIAL_FREEZE(90), + + /** + * Both directions restricted, barring the credits that must always be let through. + */ + INOPERATIVE(180); + + /** Days past the expiry date at which this stage begins. */ + private final int fromDaysOverdue; + + KycExpiryStage(final int fromDaysOverdue) { + this.fromDaysOverdue = fromDaysOverdue; + } + + public int getFromDaysOverdue() { + return fromDaysOverdue; + } + + /** + * The stage an account sits at, given how far past its re-KYC due date it is. + * + * @param daysOverdue + * days since the expiry date; zero or negative means the KYC has not lapsed + * @return the stage, or {@code null} where the KYC is still current and nothing applies + */ + public static KycExpiryStage fromDaysOverdue(final long daysOverdue) { + if (daysOverdue < 0) { + return null; + } + if (daysOverdue >= INOPERATIVE.fromDaysOverdue) { + return INOPERATIVE; + } + if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) { + return PARTIAL_FREEZE; + } + return GRACE; + } + + public boolean isGrace() { + return this == GRACE; + } + + public boolean isPartialFreeze() { + return this == PARTIAL_FREEZE; + } + + public boolean isInoperative() { + return this == INOPERATIVE; + } + + /** Whether debits should be stopped at this stage. */ + public boolean blocksDebits() { + return this == PARTIAL_FREEZE || this == INOPERATIVE; + } + + /** Whether ordinary credits should be stopped at this stage. */ + public boolean blocksCredits() { + return this == INOPERATIVE; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index 1bde1ad04..f47a04cf5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -258,7 +258,6 @@ public class SavingsApiConstants { public static final String neftFacilityParamName = "neftFacility"; public static final String rtgsFacilityParamName = "rtgsFacility"; public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility"; - public static final String withdrawalFacilityParamName = "withdrawalFacility"; public static final String depositFacilityKycParamName = "depositFacilityKyc"; public static final String virtualDebitCardParamName = "virtualDebitCard"; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java index c91a00cac..90bae273a 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java @@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom { @Column(name = "inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java index 450a714ba..4bdecc85c 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java @@ -378,7 +378,6 @@ public class CurrentProductAssembler { config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false); config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false); config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false); - config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false); config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false); config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false); return config; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java index cd355ff5b..f0665870f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java @@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } @@ -740,7 +746,11 @@ public class CashierDepositRequestApiResource { .withJson(payload.toString()) .build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // Suppressed for the same reason as the savings path above. Current accounts raise no + // voucher of their own today, so this changes nothing now; it is here so that the day one + // is added, this register does not silently start double-counting again. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 15d6924a5..1a2b36cc3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -13,6 +13,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; @@ -24,6 +25,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashreports") @Component @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class CashReportApiResource { @@ -34,6 +36,24 @@ public class CashReportApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); + /** + * Excludes reversed counter transactions from a cash figure. + * + * m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the + * counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its + * breakdown behind and went on inflating the drawer for good. Every other leg of the cash + * position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against + * its own reversal flag; this is the same guard for the counter, read off the transaction the + * breakdown belongs to. + * + * NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD + * register keys its rows by its own request id, not a savings transaction -- is kept rather than + * silently dropped. + */ + private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" + + " SELECT 1 FROM m_savings_account_transaction rt " + + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "; + /** * Day book — every voucher raised on one date, in one list, newest first. * @@ -72,7 +92,11 @@ public class CashReportApiResource { // Cashier deposits — money in, so the credit side. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type, + SELECT r.voucher_no, + -- Same rule as the withdrawal register below: an approved deposit is cash in + -- the drawer, a pending one is a request nobody has taken money for yet. + CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section, + 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type, r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id, r.submitted_at, r.actioned_at, @@ -84,13 +108,31 @@ public class CashReportApiResource { // Clerk withdrawals — money out, so the debit side. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type, + SELECT r.voucher_no, + -- Filed by what happened to the money, not by who typed the request. Once the + -- notes are handed over it is the cashier's drawer that is short, so the row + -- belongs in their section; while it is still pending or was rejected no cash + -- has moved and it is the clerk's outstanding request. + CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section, + 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type, r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status, - r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id, + COALESCE(paid.officer_username, r.clerk_name) AS handled_by, + r.manager_name AS approved_by, r.tok_no AS txn_id, r.submitted_at, r.actioned_at, """ + verifyCols + """ FROM m_clerk_withdrawal_request r """ + verifyJoin + """ + -- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it + -- is filed under whoever's cash actually moved. Sectioning it by the maker put + -- every counter payment in the clerk's column and left the cashier's own section + -- short by exactly what they had paid out -- the one figure their drawer is + -- checked against. The payer is read from the denomination breakdown, which is + -- written when the cash is handed over, and the By column names them too. + LEFT JOIN LATERAL ( + SELECT d.officer_username FROM m_savings_txn_denomination d + WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT) + AND d.officer_username IS NOT NULL + LIMIT 1) paid ON TRUE WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); @@ -125,7 +167,7 @@ public class CashReportApiResource { // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a // bank transfer moves it out (DR) — matching how the voucher itself was numbered. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, + SELECT r.voucher_no, 'CLERK' AS section, CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side, r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type, r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name, @@ -137,6 +179,37 @@ public class CashReportApiResource { WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Cash taken at the counter for a Demand Draft — a receipt, so the credit side. + // + // The Sponsor DD register is not one of the three above and has no voucher_no of its own, so + // the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a + // day book with an empty CR column and no sign of the money. The voucher is joined the other + // way round, from m_voucher.reference_id, which is where the link actually lives. + // + // Only the cash-funded drafts appear, and only those the cashier took the notes for + // directly: an account-debit or cheque draft never crosses the counter, and one funded from + // a cashier deposit request is already listed above under that register's own row. + rows.addAll(jdbcTemplate.query(""" + SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind, + NULL AS account_no, 'GL' AS account_type, + COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party, + r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount, + 'CASH' AS payment_type_name, + CONCAT('DD for ', r.beneficiary_name) AS description, + r.status, mk.username AS handled_by, ck.username AS approved_by, + CAST(r.id AS VARCHAR) AS txn_id, + r.created_date AS submitted_at, r.checker_date AS actioned_at, + """ + verifyCols + """ + FROM m_sponsor_dd_issue_request r + LEFT JOIN m_appuser mk ON mk.id = r.maker_id + LEFT JOIN m_appuser ck ON ck.id = r.checker_id + LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST' + AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%' + """ + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """ + WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL + AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be // ordered as one result set, and the row count for a single day is small. final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); @@ -195,6 +268,7 @@ public class CashReportApiResource { result.put("date", txnDate); result.put("rows", filtered); result.put("count", filtered.size()); + // Counter figures: the credit and debit the customer side of the day adds up to. result.put("creditTotal", creditTotal); result.put("debitTotal", debitTotal); result.put("netTotal", creditTotal.subtract(debitTotal)); @@ -204,6 +278,10 @@ public class CashReportApiResource { private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { final Map row = new LinkedHashMap<>(); row.put("voucherNo", rs.getString("voucher_no")); + // Which part of the day's work this row belongs to. The registers do not divide along + // maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says + // where the entry came from, not who typed it. + row.put("section", rs.getString("section")); row.put("side", rs.getString("side")); row.put("kind", rs.getString("kind")); row.put("accountNo", rs.getString("account_no")); @@ -241,12 +319,21 @@ public class CashReportApiResource { row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString()); return row; } - @GET @Path("closing") public String closing(@QueryParam("date") String date) { final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + // A closing report for a cashier is that one drawer, so it is scoped the way `excess` and + // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch. + final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering(); + + // The predicate goes through m_appuser because m_savings_txn_denomination records the acting + // user in officer_id, not the m_cashiers row -- the same join the position query uses. + final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" + + " SELECT au.id FROM m_appuser au " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + // Single query — conditional aggregation for deposit, withdrawal, net per denomination final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " + "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, " @@ -255,10 +342,16 @@ public class CashReportApiResource { + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " + "FROM m_savings_txn_denomination std " + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " - + "WHERE std.txn_date = CAST(? AS DATE) " + + "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind " + "ORDER BY std.denomination_value DESC, dm.kind"; + final List args = new ArrayList<>(); + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { long depositCount = rs.getLong("deposit_count"); long withdrawalCount = rs.getLong("withdrawal_count"); @@ -275,7 +368,7 @@ public class CashReportApiResource { row.put("netCount", depositCount - withdrawalCount); row.put("netAmount", depositAmount.subtract(withdrawalAmount)); return row; - }, txnDate); + }, args.toArray()); BigDecimal depositTotal = BigDecimal.ZERO; BigDecimal withdrawalTotal = BigDecimal.ZERO; @@ -285,6 +378,7 @@ public class CashReportApiResource { } // Cash exchange rows — separate section (denomination swap, net monetary = 0) + final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : ""; final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, " + "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, " + "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, " @@ -294,10 +388,16 @@ public class CashReportApiResource { + "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id " + "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id " + "WHERE ce.exchange_date = CAST(? AS DATE) " - + "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + + "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter + "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind " + "ORDER BY ced.denomination_value DESC, dm.kind"; + final List exArgs = new ArrayList<>(); + exArgs.add(txnDate); + if (scopedCashierId != null) { + exArgs.add(scopedCashierId); + } + final List> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> { long receivedCount = rs.getLong("received_count"); long givenCount = rs.getLong("given_count"); @@ -314,18 +414,96 @@ public class CashReportApiResource { row.put("netCount", receivedCount - givenCount); row.put("netAmount", receivedAmount.subtract(givenAmount)); return row; - }, txnDate); + }, exArgs.toArray()); + + // Cash taken over the counter for a Demand Draft. + // + // A cash DD asks for a denomination breakdown but does not insist on one, so most are + // recorded with none at all -- and the whole of this report is built from breakdowns. The + // money is real: the ledger holds it (Cash at Vault is debited when the notes are taken) + // and the cashier is holding the notes, yet the drawer figure omitted every rupee of it. + // + // Counted from the request itself rather than from a breakdown, so it appears whether or not + // the notes were itemised. Excluded are DDs funded through a cashier deposit request: that + // register counted the same notes already, and adding them here would count them twice. + final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN (" + + " SELECT au.id FROM m_appuser au " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) " + + "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count " + + "FROM m_sponsor_dd_issue_request dd " + + "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL " + + "AND dd.status <> 'REJECTED' " + + "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter; + + final List ddArgs = new ArrayList<>(); + ddArgs.add(txnDate); + if (scopedCashierId != null) { + ddArgs.add(scopedCashierId); + } + + BigDecimal ddCashTotal = BigDecimal.ZERO; + long ddCashCount = 0; + try { + final Map ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray()); + ddCashTotal = (BigDecimal) ddRow.get("cash_total"); + ddCashCount = ((Number) ddRow.get("cash_count")).longValue(); + } catch (final RuntimeException e) { + // A tenant without the Sponsor DD tables still gets its closing report. + log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e); + } Map result = new LinkedHashMap<>(); result.put("date", txnDate); result.put("rows", rows); + // The denomination-wise total, which is what the rows below add up to. result.put("depositTotal", depositTotal); result.put("withdrawalTotal", withdrawalTotal); - result.put("netTotal", depositTotal.subtract(withdrawalTotal)); + // Reported separately as well, so a screen can show where the difference comes from rather + // than presenting a deposit total its own rows do not explain. + result.put("ddCashTotal", ddCashTotal); + result.put("ddCashCount", ddCashCount); + result.put("depositTotalWithDd", depositTotal.add(ddCashTotal)); + result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal)); result.put("exchangeRows", exchangeRows); + result.put("cashierId", scopedCashierId); + result.put("scopedToCashier", scopedCashierId != null); + + // Opening and closing cash in the drawer. + // + // netTotal above is only the day's counter movement, which is not what the cashier is + // holding. A closing figure has to carry the previous balance forward, so it is derived from + // the cumulative position -- vault allocations, settlements, inter-cashier legs and + // exchanges as well as the counter work -- read once as of the day before and once as of the + // day itself. The gap between the two is the day's entire cash movement, a wider figure than + // deposits minus withdrawals, which is why both are reported rather than one derived from + // the other: when they differ, the difference is vault and inter-cashier traffic. + // + // Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the + // branch day register, not to this report. + if (scopedCashierId != null) { + final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId, + LocalDate.parse(txnDate).minusDays(1).toString()); + final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate); + result.put("openingBalance", openingBalance); + result.put("closingBalance", closingBalance); + result.put("cashMovement", closingBalance.subtract(openingBalance)); + } return gson.toJson(result); } + /** + * What one cashier is holding at the end of a given date, in rupees. + * + * Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing + * report and the position screen can never disagree about the same drawer. + */ + private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) { + final Map position = buildPositionForCashier(cashierId, asOfDate); + final Object total = position.get("totalAmount"); + return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO; + } + @GET @Path("excess") public String excess(@QueryParam("threshold") BigDecimal threshold, @@ -672,7 +850,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Settlements back to vault @@ -698,7 +876,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Exchange IN: customer gave denomination to cashier (RECEIVED direction) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 90007a91a..0d8e7fd68 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.springframework.jdbc.core.JdbcTemplate; @@ -31,6 +32,8 @@ 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; import org.springframework.transaction.PlatformTransactionManager; @@ -41,6 +44,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashsettlement") @Component @RequiredArgsConstructor +@Slf4j @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) public class CashSettlementApiResource { @@ -52,6 +56,7 @@ public class CashSettlementApiResource { private final PlatformTransactionManager transactionManager; private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; + private final VoucherService voucherService; private final Gson gson = new Gson(); @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 2dafeb28b..33bc6756b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -788,7 +788,13 @@ public class ClerkWithdrawalRequestApiResource { .withJson(payload.toString()) .build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // The withdrawal register raised its own voucher at submission and attaches the member and + // till sides to it on approval, so the savings command must not raise a second one over the + // same money -- one withdrawal was appearing twice on the cashier's payment report, once + // under the register's number and once under the savings transaction's, doubling the day's + // cash paid out. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } @@ -816,7 +822,11 @@ public class ClerkWithdrawalRequestApiResource { .withJson(payload.toString()) .build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // Suppressed for the same reason as the savings path above. Current accounts raise no + // voucher of their own today, so this changes nothing now; it is here so that the day one + // is added, this register does not silently start double-counting again. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java index 9de9c6acc..9c134190d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java @@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource { } return this.toApiJsonSerializer.serialize(exceptions); } + + /** + * Customers whose periodic re-KYC has already fallen due. + * + *

+ * Sits under the exceptions path because it is the same worklist to the branch — a customer the + * bank has to reach — and the outreach is recorded against the same contact log. + */ + @GET + @Path("lapsed") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve lapsed KYC records", description = "Returns clients whose periodic re-KYC due date has passed, oldest first.") + public String retrieveLapsedKyc() { + this.context.authenticatedUser(); + final List lapsed = this.readPlatformService.retrieveLapsedKyc(); + LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size()); + return this.toApiJsonSerializer.serialize(lapsed); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java new file mode 100644 index 000000000..4a87ede2d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +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; + +@Configuration +public class ApplyKycExpiryRestrictionsConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step applyKycExpiryRestrictionsStep(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) { + return new StepBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository) + .tasklet(applyKycExpiryRestrictionsTasklet, transactionManager).build(); + } + + @Bean + public Job applyKycExpiryRestrictionsJob(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) { + return new JobBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository) + .start(applyKycExpiryRestrictionsStep(applyKycExpiryRestrictionsTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java new file mode 100644 index 000000000..2b769e7ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService; +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; +import org.springframework.stereotype.Component; + +/** + * Nightly pass over customers whose periodic re-KYC has lapsed, applying the restriction each one + * has reached and releasing those whose KYC has since been renewed. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class ApplyKycExpiryRestrictionsTasklet implements Tasklet { + + private final KycExpiryRestrictionService kycExpiryRestrictionService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + kycExpiryRestrictionService.applyRestrictions(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java index 93c7e7569..664ebb443 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java @@ -35,4 +35,14 @@ public interface ClientKycReadPlatformService { java.util.List retrieveActiveKycExceptions(); java.util.List retrieveResolvedKycExceptions(); + + /** + * KYC records whose periodic re-KYC due date has already passed, oldest first. + * + *

+ * These are the customers the branch has to chase and, once the grace period runs out, the + * accounts the restriction ladder acts on. A record whose renewal is still ahead is not + * included: nothing is owed on it yet. + */ + java.util.List retrieveLapsedKyc(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index a9dd3e91d..41ece59e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -25,6 +25,7 @@ import org.apache.fineract.portfolio.client.data.ClientKycData; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepository; import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.client.domain.ClientKycStatus; import org.apache.fineract.portfolio.client.domain.ClientKycType; import org.apache.fineract.portfolio.client.domain.KycRiskCategory; @@ -159,4 +160,12 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .map(this::toData) .collect(java.util.stream.Collectors.toList()); } + + @Override + public java.util.List retrieveLapsedKyc() { + return this.kycRepository + .findLapsedAsOn(DateUtils.getBusinessLocalDate(), ClientKycStatus.REJECTED.getValue()).stream() + .map(this::toData) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java new file mode 100644 index 000000000..38fdcec07 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +/** + * Applies and lifts the account restrictions that follow a lapsed periodic re-KYC. + */ +public interface KycExpiryRestrictionService { + + /** + * Walks every lapsed KYC, applies the stage each one has reached, and releases customers whose + * KYC is current again. + * + * @return what the run did, for the job log + */ + KycExpiryRunSummary applyRestrictions(); + + /** + * What one run of the ladder did. + * + * @param lapsed + * KYC records past their due date + * @param inGrace + * of those, the ones still inside the grace window with nothing restricted + * @param debitBlocked + * customers whose debits are stopped + * @param fullyBlocked + * customers whose accounts are inoperative + * @param released + * accounts freed because the KYC was renewed + */ + record KycExpiryRunSummary(int lapsed, int inGrace, int debitBlocked, int fullyBlocked, int released) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java new file mode 100644 index 000000000..dc7a16755 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java @@ -0,0 +1,134 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepository; +import org.apache.fineract.portfolio.client.domain.ClientKycStatus; +import org.apache.fineract.portfolio.client.domain.KycExpiryStage; +import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService.KycExpiryRunSummary; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies the restriction ladder that follows a lapsed periodic re-KYC, and lifts it once the KYC + * is renewed. + * + *

+ * The ladder is described on {@link KycExpiryStage}. In short: nothing happens for the grace period, + * then debits stop, then the account goes inoperative. Closure is never reached automatically. + * + *

+ * Restrictions are derived from the expiry date on every run rather than remembered, so a KYC that + * is renewed simply stops being lapsed and its accounts are released on the next pass. That also + * makes the job safe to re-run: applying a stage that is already applied changes nothing. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KycExpiryRestrictionServiceImpl implements KycExpiryRestrictionService { + + private final ClientKycRepository clientKycRepository; + private final SavingsAccountRepository savingsAccountRepository; + + @Override + @Transactional + public KycExpiryRunSummary applyRestrictions() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final List lapsed = clientKycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue()); + + int debitBlocked = 0; + int fullyBlocked = 0; + int inGrace = 0; + + for (final ClientKyc kyc : lapsed) { + final long daysOverdue = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today); + final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue); + if (stage == null || stage.isGrace()) { + // Inside the grace window nothing is restricted; the customer is only being chased. + inGrace++; + continue; + } + + final Long clientId = kyc.getClient().getId(); + final List accounts = savingsAccountRepository.findSavingAccountByClientId(clientId); + boolean changedAny = false; + + for (final SavingsAccount account : accounts) { + final boolean changed = stage.isInoperative() ? account.applyKycExpiryFullBlock() : account.applyKycExpiryDebitBlock(); + if (changed) { + savingsAccountRepository.save(account); + changedAny = true; + } + } + + if (stage.isInoperative()) { + fullyBlocked++; + } else { + debitBlocked++; + } + + if (changedAny) { + log.info("Client {} re-KYC lapsed {} days ago — {} applied to their savings accounts", clientId, daysOverdue, stage); + } + } + + final int released = releaseRenewed(today); + final KycExpiryRunSummary summary = new KycExpiryRunSummary(lapsed.size(), inGrace, debitBlocked, fullyBlocked, released); + log.info("KYC expiry restrictions applied: {}", summary); + return summary; + } + + /** + * Lifts restrictions from customers whose KYC is current again. + * + *

+ * Renewing a KYC pushes its expiry date into the future, so a renewed customer simply drops out + * of the lapsed set. Nothing records that this job was the one that blocked the account, so only + * the two states the ladder itself sets are cleared — see + * {@link SavingsAccount#releaseKycExpiryBlock()} — and a manual block is left untouched. + */ + private int releaseRenewed(final LocalDate today) { + final List current = clientKycRepository.findAllByStatus(ClientKycStatus.VERIFIED.getValue()); + int released = 0; + + for (final ClientKyc kyc : current) { + if (kyc.getExpiryDate() == null || !kyc.getExpiryDate().isAfter(today)) { + continue; + } + for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(kyc.getClient().getId())) { + if (account.releaseKycExpiryBlock()) { + savingsAccountRepository.save(account); + released++; + log.info("Client {} re-KYC renewed to {} — restrictions lifted on savings account {}", kyc.getClient().getId(), + kyc.getExpiryDate(), account.getId()); + } + } + } + return released; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java new file mode 100644 index 000000000..d98aab2a7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.JsonElement; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +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 lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.savings.service.DeafRemittanceWritePlatformService; +import org.springframework.stereotype.Component; + +/** + * Records remittances of escheated balances to RBI's Depositor Education and Awareness Fund. + * + *

+ * The transfer to RBI happens outside this system; these endpoints only write down that it was + * made, so the escheat report can distinguish money that reached the fund from money that has left + * the customer's account and gone nowhere. + */ +@Path("/v1/savingsaccounts/deaf-remittance") +@Component +@Tag(name = "DEAF Remittance", description = "Record remittance of escheated balances to RBI's DEAF.") +@RequiredArgsConstructor +public class DeafRemittanceApiResource { + + private final DeafRemittanceWritePlatformService writePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final FromJsonHelper fromJsonHelper; + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record a DEAF remittance", description = "Marks the escheated balances of the given savings accounts " + + "as remitted to RBI. Accounts that are not escheated, or already recorded, are skipped.") + public String recordRemittance(final String apiRequestBodyAsJson) { + final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson); + final JsonCommand command = JsonCommand.fromJsonElement(null, parsed, this.fromJsonHelper); + final CommandProcessingResult result = this.writePlatformService.recordRemittance(command); + return this.toApiJsonSerializer.serialize(result); + } + + @POST + @Path("{savingsAccountId}/refund") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record a claim against a remitted balance", description = "Marks that the customer claimed the balance " + + "back after it had gone to the fund, and the bank recovered it from RBI.") + public String markRefunded(@PathParam("savingsAccountId") final Long savingsAccountId, final String apiRequestBodyAsJson) { + final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson); + final JsonCommand command = JsonCommand.fromJsonElement(savingsAccountId, parsed, this.fromJsonHelper); + final CommandProcessingResult result = this.writePlatformService.markRefunded(savingsAccountId, command); + return this.toApiJsonSerializer.serialize(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java new file mode 100644 index 000000000..966c8df0a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.portfolio.savings.data.DormantAccountData; +import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService; +import org.springframework.stereotype.Component; + +/** + * The read side of the dormancy ladder: which accounts the nightly job has marked, and how long + * each has been idle. + */ +@Path("/v1/savingsaccounts/dormant") +@Component +@Tag(name = "Dormant Savings Accounts", description = "Accounts marked inactive, dormant or escheated by the dormancy job.") +@RequiredArgsConstructor +public class DormantAccountsApiResource { + + private final DormantAccountReadPlatformService readPlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve dormant accounts", description = "Accounts at any of the three dormancy marks, longest idle first. " + + "Pass subStatus to narrow to one: 100 inactive, 200 dormant, 300 escheat.") + public String retrieveDormantAccounts(@QueryParam("subStatus") final Integer subStatus, + @QueryParam("officeName") final String officeName) { + final List accounts = this.readPlatformService.retrieveDormantAccounts(subStatus, officeName); + return this.toApiJsonSerializer.serialize(accounts); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index 2fca89e25..6a3f7da8b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * Runs the day: accrue for every account, then pay out any product whose cycle ended today. @@ -50,6 +51,7 @@ public class SavingsDailyInterestOrchestrator { /** Accrue for the day, then post for any product whose cycle ends today. */ public void runForDate(final LocalDate businessDate) { accrualService.accrueForDate(businessDate); + enrolNewProducts(); final List> schedules = jdbcTemplate.queryForList(""" SELECT s.savings_product_id, s.posting_period_enum @@ -88,6 +90,66 @@ public class SavingsDailyInterestOrchestrator { } } + /** + * Enrols the product behind one account, if it is not already on the timetable. + * + *

+ * Called when an account is activated, so the first account opened on a brand-new product brings + * that product onto the timetable rather than the product waiting for the nightly sweep. The + * insert is guarded the same way as {@link #enrolNewProducts()}, so calling it for a product + * already enrolled does nothing. + */ + @Transactional + public void enrolProductOf(final Long savingsAccountId) { + try { + final int enrolled = jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_schedule + (savings_product_id, posting_period_enum, run_at_time, is_active) + SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + AND sp.deposit_type_enum = 100 + AND sp.interest_posting_period_enum IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s + WHERE s.savings_product_id = sp.id) + """, savingsAccountId); + if (enrolled > 0) { + log.info("Savings interest: enrolled the product behind account {} into the daily-interest schedule", savingsAccountId); + } + } catch (final RuntimeException e) { + // Called from activation, which must not be rolled back because the timetable could not + // be written. The nightly sweep enrols the product anyway. + log.error("Savings interest: could not enrol the product behind account {}; the nightly run will do it", + savingsAccountId, e); + } + } + + /** + * The nightly sweep: gives any savings product without a timetable one, on its own posting period. + * + *

+ * Activation enrols a product as soon as its first account opens, so this is the safety net — + * it catches a product whose accounts were activated while this was not yet wired, or where the + * enrolment on activation failed. The period comes from the product's own + * {@code interest_posting_period_enum}, so a product set to post monthly posts monthly. + */ + private void enrolNewProducts() { + final int enrolled = jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_schedule + (savings_product_id, posting_period_enum, run_at_time, is_active) + SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true + FROM m_savings_product sp + WHERE sp.deposit_type_enum = 100 + AND sp.interest_posting_period_enum IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s + WHERE s.savings_product_id = sp.id) + """); + if (enrolled > 0) { + log.info("Savings interest: enrolled {} new savings product(s) into the daily-interest schedule", enrolled); + } + } + /** * Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger * itself recorded, so the schedule and the rows can never drift apart. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index 2c00c0b66..57534016c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -118,6 +118,62 @@ public class SavingsDailyInterestService { return written; } + /** + * Opens the ledger for one newly activated account. + * + *

+ * The nightly run would pick the account up on its own, but not until tomorrow, and until then + * the account is simply absent from the ledger — which reads as though it is not being accrued at + * all. Called from activation, it writes the account's first row there and then, so an account is + * on the ledger from the day it opens. + * + *

+ * Only savings accounts are accrued. Anything else — a term or recurring deposit — is left alone, + * and a failure is logged rather than raised: the activation itself has succeeded and must not be + * rolled back because the ledger could not be opened. The nightly run will pick it up. + * + *

+ * Runs in the caller's transaction, deliberately. Activation sets the account active in that + * transaction and has not committed yet, so a REQUIRES_NEW transaction here would read the row as + * it was before — still approved, not yet active — find nothing to accrue, and return quietly + * having done nothing. Joining the caller sees the activation, and the status is not re-checked + * for the same reason: the value in the database is the pre-activation one until commit. + * + * @return true if a row was written + */ + @Transactional + public boolean accrueOnActivation(final Long savingsAccountId, final LocalDate businessDate) { + try { + final List> accounts = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? AND sp.deposit_type_enum = ? + AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ? + """, savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate); + + if (accounts.isEmpty()) { + return false; + } + + final boolean written = accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth()); + if (written) { + log.info("Savings daily interest: opened the ledger for newly activated account {}", savingsAccountId); + } + return written; + } catch (final RuntimeException e) { + log.error("Savings daily interest: could not open the ledger for account {} on activation; the nightly run will " + + "pick it up", savingsAccountId, e); + return false; + } + } + private boolean accrueOneAccount(final Map account, final LocalDate businessDate, final int financialYearBeginningMonth) { final Long accountId = ((Number) account.get("id")).longValue(); @@ -130,16 +186,19 @@ public class SavingsDailyInterestService { final int daysInYear = daysInYear(account.get("days_in_year"), businessDate); final BigDecimal minBalance = (BigDecimal) account.get("min_balance"); - // The previous row is the only thing that carries the chain forward. Its absence means - // this is the account's first day; a gap would mean a day the job never ran, and rather - // than silently re-anchoring we take the last row we do have and let the reconciliation - // report the break. + // The account's row is the only thing that carries the chain forward: it holds the balance + // the last accrued day closed on and the cycle's running total. Its absence means this is + // the account's first day. + // + // The ledger keeps one row per account, so the row is read and then rewritten in place with + // today's figures. Only a row for a day before today can be carried forward — one already + // stamped with today or later means the day has been accrued and re-reading it as "previous" + // would compound the same day's interest onto itself. final List> previous = jdbcTemplate.queryForList(""" SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, is_posting_day FROM m_savings_daily_interest WHERE savings_account_id = ? AND balance_date < ? - ORDER BY balance_date DESC LIMIT 1 """, accountId, businessDate); final BigDecimal openingBalance; @@ -152,20 +211,30 @@ public class SavingsDailyInterestService { openingBalance = BigDecimal.ZERO; carriedInterest = BigDecimal.ZERO; cycleNumber = 1; - // Cycle 1 is a stub: it starts when the account was activated, not on the grid. - cycleStart = activatedOn; - cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth); + // The first row belongs to the cycle the day it records falls in, and starts at + // activation only when that day is still inside the account's opening cycle. Anchoring + // on activation alone dates the row into a cycle that closed months ago: an account + // opened in June, first accrued in September, would be stamped with June's cycle end + // and sit there permanently past its own posting date. + cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, + financialYearBeginningMonth); + final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod, + financialYearBeginningMonth); + cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart; } else { final Map prev = previous.get(0); openingBalance = (BigDecimal) prev.get("closing_balance"); final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); - if (prevWasPosting) { - // The cycle closed yesterday: the sum went to the member and starts again at zero. + // A cycle is finished either because it was paid out or because its end date has gone + // by. The second case happens whenever the ledger has a gap over a posting date - the + // run was missed, or the account was accrued again after a break - and treating it as + // unfinished would leave the row in a cycle that ended before the day it records. + if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) { carriedInterest = BigDecimal.ZERO; cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; cycleStart = prevCycleEnd.plusDays(1); - cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod, + cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, financialYearBeginningMonth); } else { carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); @@ -232,26 +301,36 @@ public class SavingsDailyInterestService { posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum, posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET + ON CONFLICT (savings_account_id) DO UPDATE SET + balance_date = EXCLUDED.balance_date, opening_balance = EXCLUDED.opening_balance, - -- A day whose interest has already been paid keeps the balance it was left - -- with. Re-accruing that day recomputes the closing balance from - -- transactions alone, which does not include this ledger's own posting, and - -- writing it back would erase the credit from the chain: the next day would - -- open lower than the member's real balance and they would stop earning on - -- interest they have been given. - closing_balance = CASE - WHEN m_savings_daily_interest.posted_amount IS NOT NULL - THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount - ELSE EXCLUDED.closing_balance END, + -- Taken as computed. The interest this ledger posts is counted as movement on + -- the day it was paid (see dayMovement), so the recomputed closing already + -- carries it and needs no adjustment here. + closing_balance = EXCLUDED.closing_balance, daily_interest = EXCLUDED.daily_interest, -- Likewise the total that was actually paid: once money has moved on the - -- strength of this figure, recomputing it would leave the ledger disagreeing - -- with the member's passbook. A posted day is settled. + -- strength of this figure, recomputing the same day would leave the ledger + -- disagreeing with the member's passbook. A posted day is settled — but only + -- that day. The next day starts the new cycle's total from zero, which is what + -- the accrual has computed, so it is taken as given. cycle_cumulative_interest = CASE WHEN m_savings_daily_interest.posted_amount IS NOT NULL + AND m_savings_daily_interest.balance_date = EXCLUDED.balance_date THEN m_savings_daily_interest.cycle_cumulative_interest ELSE EXCLUDED.cycle_cumulative_interest END, + -- Cleared when the row moves to a new day: posted_amount and the transaction + -- behind it belong to the day they were paid on, and carrying them onto the + -- next day would make an unposted cycle look settled. + posted_amount = CASE + WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date + THEN m_savings_daily_interest.posted_amount ELSE NULL END, + savings_transaction_id = CASE + WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date + THEN m_savings_daily_interest.savings_transaction_id ELSE NULL END, + posting_run_id = CASE + WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date + THEN m_savings_daily_interest.posting_run_id ELSE NULL END, cycle_number = EXCLUDED.cycle_number, cycle_start_date = EXCLUDED.cycle_start_date, cycle_end_date = EXCLUDED.cycle_end_date, @@ -299,16 +378,21 @@ public class SavingsDailyInterestService { } /** - * The day's net movement, excluding interest postings. + * The day's net movement, interest postings included. * - * Interest postings are left out on purpose. The posting is this ledger's own output, and - * folding it back in here would let it accrue interest on itself a second time. It is added to - * the closing balance by the posting run, on the posting day, once. + * Interest credited by this ledger counts as movement like any other credit. It has to: the + * ledger holds one row per account, so a balance is only ever built from the row before it, and + * a posting left out of the movement is a posting missing from every balance that follows. The + * member is paid, their passbook shows it, and the ledger would go on earning on the balance + * they had before - the interest silently stops compounding. + * + * It cannot double-count, because a posting only ever falls on one day and the row for that day + * keeps the balance the posting left it with (see the closing_balance guard in the upsert). */ private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { final BigDecimal movement = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE - WHEN t.transaction_type_enum IN (1, 8) THEN t.amount + WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount ELSE 0 END), 0) FROM m_savings_account_transaction t diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 4c2ca6b81..44f6c4172 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -40,11 +40,24 @@ import org.springframework.transaction.annotation.Transactional; * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere * holds the figure that would say so. * - * This posts the product's total first, into Savings Interest Posting Control, moves it to Savings - * Interest Posted Control, and only then pays it out to members. The whole product settles inside - * one transaction, and both control accounts have to read zero before it is allowed to commit. A - * failure rolls the product back entirely - nobody is half paid - and a run that never reached - * commit leaves a PENDING row naming the product and date for the next run to pick up. + * This posts the product's total first, into the liability the product names as its Savings Interest + * Payable, and only then pays it out to members: + * + *

+ *   Dr  Savings Interest Paid / Interest On Savings   (expense, from the product)
+ *   Cr  Savings Interest Payable                      (liability, from the product)
+ *
+ *   Dr  Savings Interest Payable                      (per member)
+ *   Cr  Savings Control                               (per member, from the product)
+ * 
+ * + * Every account named above is read from the product's own GL configuration; none is written into + * this class. A product that configures no payable falls back to the two seeded control accounts, + * which keeps products predating that mapping posting exactly as before. + * + * The whole product settles inside one transaction, and the payable has to read zero before it is + * allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run + * that never reached commit leaves a PENDING row naming the product and date for the next run. * * The paise problem is the one worth stating plainly. Each member's interest is rounded to two * decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool @@ -61,6 +74,15 @@ public class SavingsPooledInterestPostingService { private static final int MONEY_SCALE = 2; private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + /** + * Reserved names in the product's own GL mappings, which is where the two legs of the interest + * bill are configured. The names are the contract between this service and the product screen — + * the GL codes behind them are the bank's choice, not ours, so nothing here names an account. + */ + private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable"; + private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid"; + + /** Fallback control accounts, used only where the product leaves the mappings unset. */ private static final String GL_POSTING_CONTROL = "200304"; private static final String GL_POSTED_CONTROL = "200305"; @@ -142,20 +164,34 @@ public class SavingsPooledInterestPostingService { largest.get("savings_account_id")); } - final Long postingControlGl = glAccountId(GL_POSTING_CONTROL); - final Long postedControlGl = glAccountId(GL_POSTED_CONTROL); - final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + // The expense the bill is recognised against, and the liability it sits in until it reaches + // the members. Both come from the product: "Savings Interest Paid" if the product names one, + // otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the + // liability, falling back to the control accounts only where a product configures neither. + final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), + mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS)); + final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE); + final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL)); + final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL); + final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); final String currency = (String) due.get(0).get("currency_code"); final String poolReference = "SIP-" + productId + "-" + postingDate; - // Stage 1: the product's whole interest bill, recognised and pooled. + // Stage 1: the product's whole interest bill, recognised and parked in the payable. journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); // Stage 2: the pool moves to the account it is paid out of. - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + // + // Where the product names a single payable, both control legs resolve to it and this stage + // would debit and credit the same account for the same amount — a pair of entries that say + // nothing and clutter the ledger. Only a product still on the two distinct control accounts + // needs the transfer written. + if (!postingControlGl.equals(postedControlGl)) { + journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + } // Stage 3: out to the members, one at a time. BigDecimal distributed = BigDecimal.ZERO; @@ -177,6 +213,9 @@ public class SavingsPooledInterestPostingService { creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, accountId, transactionId); + // closing_balance is raised here because the interest transaction has just been written + // and the accrual for this day has already run: nothing else will fold it in before the + // next day reads this row as its opening. jdbcTemplate.update(""" UPDATE m_savings_daily_interest SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, @@ -297,4 +336,48 @@ public class SavingsPooledInterestPostingService { LIMIT 1 """, Long.class, productId, financialAccountType); } + + /** + * The GL account a product has mapped under one of the reserved names in its own GL mappings. + * + *

+ * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of + * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in + * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's + * fixed financial account types. The name is matched across all four groups: which group a bank + * files it under is a presentation choice, and a payable configured under the wrong heading + * should still be honoured rather than silently ignored. + * + * @return the account id, or null where the product does not configure this name + */ + private Long namedGlAccount(final long productId, final String accountName) { + // The column holds an empty string on a product saved with no named mappings, and casting + // that to jsonb is an error rather than an empty document — so the cast is done inside a + // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE + // clause in any order, so guarding in the same query level as the cast would only work by + // luck. + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE m.entry ->> 'accountName' = ? + LIMIT 1 + """, Long.class, productId, accountName); + return found.isEmpty() ? null : found.get(0); + } + + private static Long firstNonNull(final Long preferred, final Long fallback) { + return preferred != null ? preferred : fallback; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java new file mode 100644 index 000000000..3c20d5ef4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java @@ -0,0 +1,194 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pushes a savings product's interest terms down onto the accounts opened under it. + * + * Fineract copies rate, posting period, compounding period, calculation type and days-in-year onto + * each account when it is created, and the interest engine reads that copy from then on. Editing the + * product changes nothing for accounts that already exist: a product moved from 7% to 10% goes on + * paying 7% on every account opened before the edit, indefinitely and silently. Because the copies + * are what the engine reads, they are also what the member is actually paid. + * + * This bank's rule is that interest follows the product, so an edit has to reach the accounts. The + * cascade runs on the same transaction as the product update, which means an edit either lands + * everywhere or nowhere - no state where the product says one thing and its accounts another. + * + * Scope is deliberately narrow: + * + * - savings only (deposit_type_enum 100). Fixed and recurring deposits keep the rate they were + * contracted at; that is the nature of a term deposit and changing it retroactively would + * rewrite an agreement. + * - active and pending accounts only. A closed account's terms are history. + * - min_balance_for_interest_calculation is never touched: it exists only on the account, so + * there is nothing on the product to cascade from. + * + * Interest already posted is not disturbed. Only future accrual moves, which is what an interest + * rate change ordinarily means. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SavingsProductTermsCascadeService { + + private static final int SAVINGS_DEPOSIT_TYPE = 100; + + /** m_savings_account.status_enum: 100 submitted/pending approval, 200 approved, 300 active. */ + private static final String CASCADEABLE_STATUSES = "(100, 200, 300)"; + + private final JdbcTemplate jdbcTemplate; + + /** + * Copies the product's interest terms onto its savings accounts. + * + * @param productId + * the product that was edited + * @param changes + * what the update actually changed, from the command result; the cascade is skipped + * when none of the interest fields are among them + * @return how many accounts were updated + */ + @Transactional + public int cascadeInterestTerms(final Long productId, final Map changes) { + if (productId == null || changes == null || changes.isEmpty()) { + return 0; + } + if (!touchesInterestTerms(changes)) { + return 0; + } + + final List> product = jdbcTemplate.queryForList(""" + SELECT nominal_annual_interest_rate, interest_compounding_period_enum, + interest_posting_period_enum, interest_calculation_type_enum, + interest_calculation_days_in_year_type_enum + FROM m_savings_product + WHERE id = ? AND deposit_type_enum = ? + """, productId, SAVINGS_DEPOSIT_TYPE); + if (product.isEmpty()) { + // Not a savings product - a fixed or recurring deposit, which is left alone. + return 0; + } + + // Only rows that actually differ are written, so the count reported is the number of + // accounts whose terms really moved rather than the size of the product's book. + final int updated = jdbcTemplate.update(""" + UPDATE m_savings_account sa + SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate, + interest_compounding_period_enum = sp.interest_compounding_period_enum, + interest_posting_period_enum = sp.interest_posting_period_enum, + interest_calculation_type_enum = sp.interest_calculation_type_enum, + interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum + FROM m_savings_product sp + WHERE sp.id = sa.product_id + AND sa.product_id = ? + AND sp.deposit_type_enum = ? + AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """ + AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate + OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum + OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum + OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum + OR sa.interest_calculation_days_in_year_type_enum + <> sp.interest_calculation_days_in_year_type_enum) + """, productId, SAVINGS_DEPOSIT_TYPE); + + if (updated > 0) { + log.info("Savings product {}: interest terms cascaded to {} account(s) — {}", productId, updated, + changes.keySet()); + } + return updated; + } + + /** + * Whether the edit touched anything the accounts hold a copy of. + * + * The names are the API's own parameter names, which is what the update result reports. + */ + private boolean touchesInterestTerms(final Map changes) { + return changes.containsKey("nominalAnnualInterestRate") + || changes.containsKey("interestCompoundingPeriodType") + || changes.containsKey("interestPostingPeriodType") + || changes.containsKey("interestCalculationType") + || changes.containsKey("interestCalculationDaysInYearType"); + } + + /** + * Brings every savings account in line with its product, regardless of what changed. + * + * For the accounts that drifted before the cascade existed - in this tenant, accounts opened on + * a 7% daily copy of a product that now reads 10% quarterly. + * + * @return how many accounts were updated + */ + @Transactional + public int realignAllAccounts() { + final int updated = jdbcTemplate.update(""" + UPDATE m_savings_account sa + SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate, + interest_compounding_period_enum = sp.interest_compounding_period_enum, + interest_posting_period_enum = sp.interest_posting_period_enum, + interest_calculation_type_enum = sp.interest_calculation_type_enum, + interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum + FROM m_savings_product sp + WHERE sp.id = sa.product_id + AND sp.deposit_type_enum = ? + AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """ + AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate + OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum + OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum + OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum + OR sa.interest_calculation_days_in_year_type_enum + <> sp.interest_calculation_days_in_year_type_enum) + """, SAVINGS_DEPOSIT_TYPE); + log.info("Savings interest terms realigned to product on {} account(s)", updated); + return updated; + } + + /** The accounts whose terms differ from their product, for a report or a pre-flight check. */ + public List> findDrifted() { + return jdbcTemplate.queryForList(""" + SELECT sa.id AS account_id, sa.account_no, sp.id AS product_id, sp.name AS product_name, + sa.nominal_annual_interest_rate AS account_rate, + sp.nominal_annual_interest_rate AS product_rate, + sa.interest_posting_period_enum AS account_posting, + sp.interest_posting_period_enum AS product_posting, + sa.interest_calculation_type_enum AS account_calculation, + sp.interest_calculation_type_enum AS product_calculation + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sp.deposit_type_enum = ? + AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """ + AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate + OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum + OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum + OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum + OR sa.interest_calculation_days_in_year_type_enum + <> sp.interest_calculation_days_in_year_type_enum) + ORDER BY sp.id, sa.id + """, SAVINGS_DEPOSIT_TYPE); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java index 82b77585a..5b8a3977e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -53,8 +54,35 @@ public class SavingsDailyInterestApiResource { private final JdbcTemplate jdbcTemplate; private final SavingsDailyInterestService accrualService; private final SavingsDailyInterestOrchestrator orchestrator; + private final SavingsProductTermsCascadeService termsCascadeService; private final Gson gson = new Gson(); + /** + * The accounts whose interest terms differ from the product they were opened under. + * + * A product edit cascades to its accounts now, but accounts opened before that was so can still + * be sitting on the terms they were created with — and those copies are what the engine pays on. + */ + @GET + @Path("/drifted") + public String drifted() { + final List> rows = termsCascadeService.findDrifted(); + final Map result = new LinkedHashMap<>(); + result.put("count", rows.size()); + result.put("accounts", rows); + return gson.toJson(result); + } + + /** Brings every savings account's interest terms back in line with its product. */ + @POST + @Path("/realign") + public String realign() { + final int updated = termsCascadeService.realignAllAccounts(); + final Map result = new LinkedHashMap<>(); + result.put("accountsRealigned", updated); + return gson.toJson(result); + } + /** The savings products carrying a ledger, with where each one is in its timetable. */ @GET @Path("/products") @@ -117,6 +145,119 @@ public class SavingsDailyInterestApiResource { return gson.toJson(rows); } + /** + * One row per account: where each one stands right now. + * + *

+ * The ledger behind this is day-by-day and has to stay that way — interest is worked out on the + * balance each day carried, and a reversed backdated entry means every day after it is + * recomputed. What a reader wants, though, is the current position without reading thirty rows + * to find it, which is what {@code v_savings_interest_summary} gives: the latest day's figures + * per account, with the cycle total and the lifetime posted total beside them. + * + *

+ * Optionally narrowed to one product, one office, or one account. + */ + @GET + @Path("/summary") + public String summary(@QueryParam("productId") final Long productId, @QueryParam("officeName") final String officeName, + @QueryParam("accountNo") final String accountNo) { + final List args = new java.util.ArrayList<>(); + final StringBuilder sql = new StringBuilder("SELECT * FROM v_savings_interest_summary WHERE 1 = 1"); + + if (productId != null) { + sql.append(" AND savings_product_id = ?"); + args.add(productId); + } + if (officeName != null && !officeName.isBlank()) { + sql.append(" AND office_name = ?"); + args.add(officeName); + } + if (accountNo != null && !accountNo.isBlank()) { + sql.append(" AND account_no = ?"); + args.add(accountNo); + } + sql.append(" ORDER BY account_no"); + + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray())); + } + + /** + * Interest actually paid to members, one row per posting. + * + *

+ * Read from the savings transactions rather than the daily ledger. The ledger holds one row per + * account carrying the current cycle, and clears the posted amount when the next cycle opens, so + * it cannot answer what was paid last quarter. The transaction is the payment itself and stays. + * + *

+ * {@code period} narrows to one posting rhythm, matching the product's own + * {@code interest_posting_period_enum}: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly, + * 7 annual. A report screen shows each rhythm on its own, because a monthly product's + * month-end posting and a quarterly product's quarter-end posting are different events and + * mixing them reads as one uneven series. + */ + @GET + @Path("/postings") + public String postings(@QueryParam("period") final Integer period, @QueryParam("productId") final Long productId, + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("officeName") final String officeName) { + final List args = new java.util.ArrayList<>(); + final StringBuilder sql = new StringBuilder(""" + SELECT t.id AS transaction_id, + t.savings_account_id AS savings_account_id, + sa.account_no AS account_no, + sa.client_id AS client_id, + c.display_name AS client_name, + o.name AS office_name, + sa.product_id AS savings_product_id, + sp.name AS product_name, + sp.interest_posting_period_enum AS posting_period_enum, + sp.nominal_annual_interest_rate AS interest_rate, + sa.currency_code AS currency_code, + -- Formatted here rather than left as a date: this resource serialises with a + -- plain Gson, which renders a java.sql.Date as an object the browser cannot + -- read back, and every posting showed as "Invalid Date". + TO_CHAR(t.transaction_date, 'YYYY-MM-DD') AS posting_date, + t.amount AS interest_amount, + t.running_balance_derived AS balance_after, + (t.running_balance_derived - t.amount) AS balance_before, + t.created_on_utc AS recorded_on + FROM m_savings_account_transaction t + JOIN m_savings_account sa ON sa.id = t.savings_account_id + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + WHERE t.transaction_type_enum = 3 + AND t.is_reversed = false + AND sp.deposit_type_enum = 100 + """); + + if (period != null) { + sql.append(" AND sp.interest_posting_period_enum = ? "); + args.add(period); + } + if (productId != null) { + sql.append(" AND sa.product_id = ? "); + args.add(productId); + } + if (fromDate != null && !fromDate.isBlank()) { + sql.append(" AND t.transaction_date >= ? "); + args.add(LocalDate.parse(fromDate)); + } + if (toDate != null && !toDate.isBlank()) { + sql.append(" AND t.transaction_date <= ? "); + args.add(LocalDate.parse(toDate)); + } + if (officeName != null && !officeName.isBlank()) { + sql.append(" AND o.name = ? "); + args.add(officeName); + } + sql.append(" ORDER BY t.transaction_date DESC, sa.account_no"); + + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray())); + } + /** What each posting run pooled and paid, and whether it finished. */ @GET @Path("/runs") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java new file mode 100644 index 000000000..ada5c7a5c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +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; + +@Configuration +public class SavingsDailyInterestConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step savingsDailyInterestStep(SavingsDailyInterestTasklet savingsDailyInterestTasklet) { + return new StepBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository) + .tasklet(savingsDailyInterestTasklet, transactionManager).build(); + } + + @Bean + public Job savingsDailyInterestJob(SavingsDailyInterestTasklet savingsDailyInterestTasklet) { + return new JobBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository) + .start(savingsDailyInterestStep(savingsDailyInterestTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java new file mode 100644 index 000000000..e2d1e6c62 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +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; +import org.springframework.stereotype.Component; + +/** + * Runs the day's savings interest: accrue every account's interest for the business date, then post + * for the products whose cycle ends on it. + * + *

+ * Until this existed the orchestrator was only reachable through its API, so the ledger accrued for + * as long as somebody kept calling it and posted only when somebody remembered to. Interest a bank + * owes its depositors is not something to run by hand. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class SavingsDailyInterestTasklet implements Tasklet { + + private final SavingsDailyInterestOrchestrator orchestrator; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + orchestrator.runForDate(DateUtils.getBusinessLocalDate()); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java new file mode 100644 index 000000000..afede6a65 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +/** + * One account that has gone quiet, as the dormancy reports read it. + * + *

+ * {@code lastCustomerTransactionDate} is the heart of it: RBI measures the silence from the last + * transaction the customer made, so interest the bank posts and charges it recovers are + * excluded when this is worked out. Where an account has never been transacted on, the activation + * date stands in — the clock has to start somewhere, and that is the day the account began. + */ +@Getter +@Builder +public class DormantAccountData { + + private final Long accountId; + private final String accountNo; + private final Long clientId; + private final String clientName; + private final String officeName; + private final String productName; + private final BigDecimal balance; + private final String mobileNo; + + /** 100 inactive, 200 dormant, 300 escheat. */ + private final Integer subStatus; + private final String subStatusCode; + + private final LocalDate lastCustomerTransactionDate; + private final Long daysIdle; + + /** The product's own periods, so the report can say when the next mark falls. */ + private final Long daysToInactive; + private final Long daysToDormancy; + private final Long daysToEscheat; + + /** Escheat only — the day the account was closed and the balance taken out. */ + private final LocalDate closedOnDate; + + /** + * Escheat only — the day the balance was actually remitted to RBI's DEAF, or null if that has + * not been recorded. A null here on an escheated account means the money is out of the + * customer's account but not yet accounted for as having reached the fund. + */ + private final LocalDate deafRemittedOn; + + /** RBI's acknowledgement reference for the remittance. */ + private final String deafReference; + + /** Set if the customer later claimed the balance and it was recovered from the fund. */ + private final LocalDate deafRefundedOn; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java index 34f73ee2e..d0f945f84 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -88,7 +88,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); - FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD"); FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING"); @@ -155,8 +154,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes); - populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(), - pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes); populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(), @@ -418,7 +415,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfPresent(fc, "neftFacility", p.getNeftFacility()); putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); putIfPresent(fc, "internetBanking", p.getInternetBanking()); @@ -449,7 +445,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility()); addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current != null && current.isInterBankTotalCapFacility()); - addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility()); addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc()); addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard()); addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking()); @@ -534,7 +529,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfNotNull(fc, "neftFacility", p.getNeftFacility()); putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); putIfNotNull(fc, "internetBanking", p.getInternetBanking()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index bf6d014e8..cbc3c5a63 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -91,7 +91,6 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead cfg.put("neftFacility", rs.getBoolean("neft_facility")); cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility")); cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility")); - cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility")); cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc")); cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card")); cfg.put("internetBanking", rs.getBoolean("internet_banking")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index c6ee7f823..95216e8e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -505,7 +505,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri cfg.setNeftFacility(getBool(fcJson, "neftFacility")); cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); - cfg.setWithdrawalFacility(getBool(fcJson, "withdrawalFacility")); cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); cfg.setInternetBanking(getBool(fcJson, "internetBanking")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java new file mode 100644 index 000000000..fb928f7fc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface DeafRemittanceWritePlatformService { + + /** + * Records that the escheated balances of the given accounts were remitted to RBI's DEAF. + * + *

+ * Accounts that are not escheated, or already carry a remittance, are skipped rather than + * rejected — a batch selected in the UI should not fail whole because one row was sent twice. + */ + CommandProcessingResult recordRemittance(JsonCommand command); + + /** + * Records that the customer claimed an already-remitted balance back and the bank recovered it + * from the fund. + */ + CommandProcessingResult markRefunded(Long savingsAccountId, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java new file mode 100644 index 000000000..509d07fc8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java @@ -0,0 +1,136 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +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.portfolio.savings.domain.DeafRemittance; +import org.apache.fineract.portfolio.savings.domain.DeafRemittanceRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Records that escheated balances were remitted to RBI's Depositor Education and Awareness Fund. + * + *

+ * The remittance itself is a transfer made outside this system on RBI's own schedule. All that + * happens here is the recording of it, which is what lets the escheat report tell money that has + * reached the fund from money still sitting with the bank. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DeafRemittanceWritePlatformServiceImpl implements DeafRemittanceWritePlatformService { + + private final PlatformSecurityContext context; + private final DeafRemittanceRepository remittanceRepository; + private final SavingsAccountRepository savingsAccountRepository; + + @Override + @Transactional + public CommandProcessingResult recordRemittance(final JsonCommand command) { + final Long userId = this.context.authenticatedUser().getId(); + + final String[] accountIds = command.arrayValueOfParameterNamed("savingsAccountIds") == null ? new String[0] + : command.arrayOfParameterNamed("savingsAccountIds").asList().stream().map(e -> e.getAsString()).toArray(String[]::new); + final LocalDate remittedOn = command.localDateValueOfParameterNamed("remittedOn"); + final String deafReference = command.stringValueOfParameterNamed("deafReference"); + final String remarks = command.stringValueOfParameterNamed("remarks"); + + final List errors = new ArrayList<>(); + final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("deafremittance"); + + if (accountIds.length == 0) { + validator.reset().parameter("savingsAccountIds").failWithCode("cannot.be.empty"); + } + if (remittedOn == null) { + validator.reset().parameter("remittedOn").failWithCode("cannot.be.blank"); + } else if (DateUtils.isAfterBusinessDate(remittedOn)) { + // A remittance cannot have been made on a day that has not arrived. + validator.reset().parameter("remittedOn").value(remittedOn).failWithCode("cannot.be.in.the.future"); + } + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException(errors); + } + + int recorded = 0; + int skipped = 0; + + for (final String rawId : accountIds) { + final Long accountId = Long.valueOf(rawId); + final SavingsAccount account = this.savingsAccountRepository.findById(accountId).orElse(null); + + if (account == null) { + skipped++; + continue; + } + // Only an escheated balance is owed to the fund; anything else has not left the customer. + if (!SavingsAccountSubStatusEnum.ESCHEAT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()))) { + log.warn("Savings account {} is not escheated — remittance not recorded", accountId); + skipped++; + continue; + } + // A balance goes to the fund once. Recording it twice would overstate what was sent. + if (this.remittanceRepository.existsBySavingsAccountId(accountId)) { + skipped++; + continue; + } + + final LocalDate escheatedOn = account.getClosedOnDate() != null ? account.getClosedOnDate() : remittedOn; + + this.remittanceRepository.save(DeafRemittance.record(accountId, account.getClient().getId(), + account.getSummary().getAccountBalance(), escheatedOn, remittedOn, deafReference, remarks, userId)); + recorded++; + } + + log.info("DEAF remittance recorded for {} account(s), {} skipped, reference {}", recorded, skipped, deafReference); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()) + .with(java.util.Map.of("recorded", recorded, "skipped", skipped)).build(); + } + + @Override + @Transactional + public CommandProcessingResult markRefunded(final Long savingsAccountId, final JsonCommand command) { + this.context.authenticatedUser(); + + final DeafRemittance remittance = this.remittanceRepository.findBySavingsAccountId(savingsAccountId).orElseThrow( + () -> new IllegalStateException("No DEAF remittance recorded for savings account " + savingsAccountId)); + + final LocalDate refundedOn = command.localDateValueOfParameterNamed("refundedOn"); + remittance.markRefunded(refundedOn != null ? refundedOn : DateUtils.getBusinessLocalDate()); + this.remittanceRepository.save(remittance); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(remittance.getId()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java new file mode 100644 index 000000000..8e1094b4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.util.List; +import org.apache.fineract.portfolio.savings.data.DormantAccountData; + +/** + * Reads the accounts the dormancy job has marked inactive, dormant or escheated. + */ +public interface DormantAccountReadPlatformService { + + /** + * @param subStatus + * 100 inactive, 200 dormant, 300 escheat; null for all three + * @param officeName + * restricts to one branch; null or blank for every branch + * @return the matching accounts, longest idle first + */ + List retrieveDormantAccounts(Integer subStatus, String officeName); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java new file mode 100644 index 000000000..6d5322c63 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -0,0 +1,151 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.data.DormantAccountData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +/** + * Reads back the accounts the dormancy job has marked. + * + *

+ * The job writes {@code sub_status} and nothing read it out in bulk, so the branch could not see + * which accounts had gone inactive, dormant or escheated. This is that missing read side. + * + *

+ * The idle period is recomputed here rather than stored, from the same definition the job uses — + * the last transaction of type 1 (deposit) or 2 (withdrawal), which is to say the last one the + * customer made. Interest postings and charge recoveries carry other type codes and are excluded, + * which is what keeps an account from looking active merely because the bank credited interest to + * it. + */ +@Service +@RequiredArgsConstructor +public class DormantAccountReadPlatformServiceImpl implements DormantAccountReadPlatformService { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + + /** Sub-status codes the dormancy ladder sets. */ + private static final int SUB_STATUS_INACTIVE = 100; + private static final int SUB_STATUS_DORMANT = 200; + private static final int SUB_STATUS_ESCHEAT = 300; + + @Override + public List retrieveDormantAccounts(final Integer subStatus, final String officeName) { + this.context.authenticatedUser(); + + final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) " + + "from m_savings_account_transaction sat where sat.savings_account_id = sa.id " + + "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))"; + + final StringBuilder sql = new StringBuilder(); + sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, "); + sql.append("sa.account_balance_derived as balance, sa.closedon_date as closedOnDate, "); + sql.append("c.id as clientId, c.display_name as clientName, c.mobile_no as mobileNo, "); + sql.append("o.name as officeName, sp.name as productName, "); + sql.append("sp.days_to_inactive as daysToInactive, sp.days_to_dormancy as daysToDormancy, "); + sql.append("sp.days_to_escheat as daysToEscheat, "); + sql.append("dr.remitted_on as deafRemittedOn, dr.deaf_reference as deafReference, "); + sql.append("dr.refunded_on as deafRefundedOn, "); + sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, "); + sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle "); + sql.append("from m_savings_account sa "); + sql.append("inner join m_client c on c.id = sa.client_id "); + sql.append("inner join m_office o on o.id = c.office_id "); + sql.append("inner join m_savings_product sp on sp.id = sa.product_id "); + // Left join: an escheated account has a remittance row only once the money has gone to RBI, + // and the absence of one is exactly what the report has to show. + sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id "); + // Only the three states the ladder sets. A blocked or a plainly active account is not + // dormant and does not belong on these reports. + sql.append("where sa.sub_status_enum in (?, ?, ?) "); + + final List params = new ArrayList<>(); + params.add(DateUtils.getBusinessLocalDate()); + params.add(SUB_STATUS_INACTIVE); + params.add(SUB_STATUS_DORMANT); + params.add(SUB_STATUS_ESCHEAT); + + if (subStatus != null) { + sql.append("and sa.sub_status_enum = ? "); + params.add(subStatus); + } + if (officeName != null && !officeName.isBlank()) { + sql.append("and o.name = ? "); + params.add(officeName); + } + + // Longest idle first: those are the accounts nearest their next mark. + sql.append("order by daysIdle desc, sa.account_balance_derived desc"); + + return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray()); + } + + private static final class DormantAccountMapper implements RowMapper { + + @Override + public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final int subStatus = rs.getInt("subStatus"); + final BigDecimal balance = rs.getBigDecimal("balance"); + final long daysIdle = rs.getLong("daysIdle"); + + return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo")) + .clientId(rs.getLong("clientId")).clientName(rs.getString("clientName")) + .officeName(rs.getString("officeName")).productName(rs.getString("productName")) + // A null balance reads as zero rather than as missing data — an escheated account + // has had its balance taken out and genuinely holds nothing. + .balance(balance == null ? BigDecimal.ZERO : balance).mobileNo(rs.getString("mobileNo")) + .subStatus(subStatus).subStatusCode(subStatusCode(subStatus)) + .lastCustomerTransactionDate(rs.getObject("lastCustomerTransactionDate", java.time.LocalDate.class)) + .daysIdle(daysIdle).daysToInactive(nullableLong(rs, "daysToInactive")) + .daysToDormancy(nullableLong(rs, "daysToDormancy")).daysToEscheat(nullableLong(rs, "daysToEscheat")) + .closedOnDate(rs.getObject("closedOnDate", java.time.LocalDate.class)) + .deafRemittedOn(rs.getObject("deafRemittedOn", java.time.LocalDate.class)) + .deafReference(rs.getString("deafReference")) + .deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build(); + } + + private Long nullableLong(final ResultSet rs, final String column) throws SQLException { + final long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private String subStatusCode(final int subStatus) { + return switch (subStatus) { + case SUB_STATUS_INACTIVE -> "savingsAccountSubStatus.inactive"; + case SUB_STATUS_DORMANT -> "savingsAccountSubStatus.dormant"; + case SUB_STATUS_ESCHEAT -> "savingsAccountSubStatus.escheat"; + default -> "savingsAccountSubStatus.none"; + }; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index ac6194011..ca4b88631 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -83,7 +83,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); - FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD"); } @@ -132,7 +131,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes); - populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes); populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes); } @@ -370,7 +368,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfPresent(fc, "neftFacility", p.getNeftFacility()); putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); return fc; @@ -397,7 +394,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility()); addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility()); addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility()); - addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility()); addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc()); addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard()); return result; @@ -483,7 +479,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfNotNull(fc, "neftFacility", p.getNeftFacility()); putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); data.setProposedFacilityConfig(fc); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 4205b3804..3fa41d175 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -1,1624 +1,1622 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.savings.service; - -import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_BY_DB_FIELD; -import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; - -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.accounting.common.AccountingRuleType; -import org.apache.fineract.accounting.glaccount.data.GLAccountData; -import org.apache.fineract.infrastructure.core.data.EnumOptionData; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.domain.JdbcSupport; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.Page; -import org.apache.fineract.infrastructure.core.service.PaginationHelper; -import org.apache.fineract.infrastructure.core.service.SearchParameters; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.infrastructure.security.utils.ColumnValidator; -import org.apache.fineract.organisation.monetary.data.CurrencyData; -import org.apache.fineract.portfolio.account.data.AccountTransferData; -import org.apache.fineract.portfolio.client.data.ClientData; -import org.apache.fineract.portfolio.group.data.GroupGeneralData; -import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; -import org.apache.fineract.portfolio.savings.DepositAccountType; -import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; -import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; -import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; -import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimelineData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; -import org.apache.fineract.portfolio.savings.data.SavingsProductData; -import org.apache.fineract.portfolio.savings.domain.SavingsAccount; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; -import org.apache.fineract.portfolio.tax.data.TaxComponentData; -import org.apache.fineract.portfolio.tax.data.TaxDetailsData; -import org.apache.fineract.portfolio.tax.data.TaxGroupData; -import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.ResultSetExtractor; -import org.springframework.jdbc.core.RowMapper; - -public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { - - private final PlatformSecurityContext context; - private final JdbcTemplate jdbcTemplate; - private final DatabaseSpecificSQLGenerator sqlGenerator; - - // mappers - private final SavingsAccountTransactionTemplateMapper transactionTemplateMapper; - protected SavingsAccountTransactionsMapper transactionsMapper; - private final SavingsAccountTransactionsForBatchMapper savingsAccountTransactionsForBatchMapper; - private final SavingAccountMapper savingAccountMapper; - private final SavingAccountMapperForInterestPosting savingAccountMapperForInterestPosting; - // private final SavingsAccountAnnualFeeMapper annualFeeMapper; - - // pagination - private final PaginationHelper paginationHelper; - - private final ColumnValidator columnValidator; - private final SavingsAccountAssembler savingAccountAssembler; - - private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; - - public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, - final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { - this.context = context; - this.jdbcTemplate = jdbcTemplate; - this.sqlGenerator = sqlGenerator; - this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; - this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); - this.transactionsMapper = new SavingsAccountTransactionsMapper(); - this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); - this.savingAccountMapper = new SavingAccountMapper(); - this.columnValidator = columnValidator; - this.paginationHelper = paginationHelper; - this.savingAccountMapperForInterestPosting = new SavingAccountMapperForInterestPosting(); - this.savingAccountAssembler = savingAccountAssembler; - } - - @Override - public Collection retrieveAllForLookup(final Long clientId) { - - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 "); - - final Object[] queryParameters = new Object[] { clientId }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType) { - - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? "); - - final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue() }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType, - String currencyCode) { - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? and sa.currency_code = ? "); - - final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue(), currencyCode }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Page retrieveAll(final SearchParameters searchParameters) { - - final AppUser currentUser = this.context.authenticatedUser(); - final String hierarchy = currentUser.getOffice().getHierarchy(); - final String hierarchySearchString = hierarchy + "%"; - - final StringBuilder sqlBuilder = new StringBuilder(200); - sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); - sqlBuilder.append(this.savingAccountMapper.schema()); - - sqlBuilder.append(" join m_office o on o.id = c.office_id"); - sqlBuilder.append(" where o.hierarchy like ?"); - - final Object[] objectArray = new Object[15]; - objectArray[0] = hierarchySearchString; - int arrayPos = 1; - if (searchParameters != null) { - - if (StringUtils.isNotBlank(searchParameters.getStatus())) { - if ("terminated".equalsIgnoreCase(searchParameters.getStatus())) { - sqlBuilder.append(" and sa.terminate_true = true"); - } else { - final Integer statusEnum = resolveStatusEnum(searchParameters.getStatus()); - if (statusEnum != null) { - sqlBuilder.append(" and sa.status_enum = ?"); - objectArray[arrayPos++] = statusEnum; - if (statusEnum == 100) { - sqlBuilder.append(" and (sa.terminate_true = false or sa.terminate_true is null)"); - } - } - } - } - - if (StringUtils.isNotBlank(searchParameters.getExternalId())) { - sqlBuilder.append(" and sa.external_id = ?"); - objectArray[arrayPos] = searchParameters.getExternalId(); - arrayPos = arrayPos + 1; - } - if (searchParameters.getOfficeId() != null) { - sqlBuilder.append(" and c.office_id = ?"); - objectArray[arrayPos++] = searchParameters.getOfficeId(); - } - if (StringUtils.isNotBlank(searchParameters.getAccountNo())) { - sqlBuilder.append(" and sa.account_no like ?"); - objectArray[arrayPos++] = "%" + searchParameters.getAccountNo() + "%"; - } - if (StringUtils.isNotBlank(searchParameters.getName())) { - sqlBuilder.append(" and c.display_name like ?"); - objectArray[arrayPos++] = "%" + searchParameters.getName() + "%"; - } - if (searchParameters.getDepositType() != null) { - sqlBuilder.append(" and sa.deposit_type_enum = ?"); - objectArray[arrayPos++] = searchParameters.getDepositType(); - } - if (searchParameters.hasFromDate()) { - sqlBuilder.append(" and sa.activatedon_date >= ?"); - objectArray[arrayPos++] = searchParameters.getFromDate(); - } - if (searchParameters.hasToDate()) { - sqlBuilder.append(" and sa.activatedon_date <= ?"); - objectArray[arrayPos++] = searchParameters.getToDate(); - } - if (searchParameters.hasAsOnDate()) { - sqlBuilder.append(" and sa.activatedon_date <= ?"); - objectArray[arrayPos++] = searchParameters.getAsOnDate(); - } - if (searchParameters.hasOrderBy()) { - sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); - this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); - - if (searchParameters.hasSortOrder()) { - sqlBuilder.append(' ').append(searchParameters.getSortOrder()); - this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getSortOrder()); - } - } else { - sqlBuilder.append(" order by sa.id desc"); - } - - if (searchParameters.hasLimit()) { - sqlBuilder.append(" "); - if (searchParameters.hasOffset()) { - sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit(), searchParameters.getOffset())); - } else { - sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit())); - } - } - } - final Object[] finalObjectArray = Arrays.copyOf(objectArray, arrayPos); - return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), finalObjectArray, this.savingAccountMapper); - } - - private Integer resolveStatusEnum(final String status) { - if (status == null) { - return null; - } - return switch (status.toLowerCase()) { - case "active" -> 300; - case "approved" -> 200; - case "submitted", "submittedandpendingapproval" -> 100; - case "withdrawn" -> 400; - case "rejected" -> 500; - case "closed" -> 600; - case "terminated" -> 900; - default -> { - try { - yield Integer.parseInt(status); - } catch (NumberFormatException e) { - yield null; - } - } - }; - } - - @Override - public SavingsAccountData retrieveOne(final Long accountId) { - - try { - final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; - - final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper, - new Object[] { accountId }); // NOSONAR - - // Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL) - if (accountData != null) { - final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " - + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " - + "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? " - + "ORDER BY jh.is_primary DESC, jh.id ASC"; - final List> jointHolders = this.jdbcTemplate.query(jointSql, (rs, rowNum) -> { - final java.util.Map h = new java.util.LinkedHashMap<>(); - h.put("id", rs.getLong("id")); - h.put("clientId", rs.getLong("client_id")); - h.put("cifNumber", rs.getString("cif_number")); - h.put("displayName", rs.getString("display_name")); - h.put("sharePercentage", rs.getBigDecimal("share_percentage")); - h.put("signingAuthority", rs.getString("signing_authority")); - h.put("role", rs.getString("role")); - h.put("isPrimary", rs.getBoolean("is_primary")); - h.put("isMandatory", rs.getBoolean("is_mandatory")); - return h; - }, accountId); - if (!jointHolders.isEmpty()) { - accountData.setJointHolders(jointHolders); - } - } - - return accountData; - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - - @Override - public List retrieveAllTransactionData(final List refNo) throws DataAccessException { - String inSql = String.join(",", Collections.nCopies(refNo.size(), "?")); - String sql = "select " + this.savingsAccountTransactionsForBatchMapper.schema() + " where tr.ref_no in (%s)"; - Object[] params = new Object[refNo.size()]; - int i = 0; - for (String element : refNo) { - params[i] = element; - i++; - } - return this.jdbcTemplate.query(String.format(sql, inSql), this.savingsAccountTransactionsForBatchMapper, params); - } - - @Override - public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, - final Integer status, final Long maxSavingsId) { - LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); - String sql = "select " + this.savingAccountMapperForInterestPosting.schema() - + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; - if (backdatedTxnsAllowedTill) { - sql = sql - + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; - } - - sql = sql + " and (sa.interest_posted_till_date is null or sa.interest_posted_till_date <= ? ) "; - // #audit backward compatibility - sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; - - List savingsAccountDataList = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR - new Object[] { maxSavingsId, status, pageSize, yesterday }); - for (SavingsAccountData savingsAccountData : savingsAccountDataList) { - this.savingAccountAssembler.assembleSavings(savingsAccountData); - } - return savingsAccountDataList; - } - - private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { - - private final String schemaSql; - - SavingAccountMapperForInterestPosting() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); - sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); - sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); - sqlBuilder.append("sa.status_enum as statusEnum, "); - sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); - sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); - sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); - sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); - sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); - sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); - sqlBuilder.append("sa.closedon_date as closedOnDate,"); - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); - sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); - sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); - sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); - sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); - sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); - sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); - sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); - sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); - sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); - sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); - sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); - sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); - sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); - sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); - sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); - sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); - sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); - sqlBuilder.append("sa.account_balance_derived as accountBalance, "); - sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); - sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); - sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); - sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); - sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); - sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); - sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); - sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); - sqlBuilder.append("sa.withhold_tax as withHoldTax, "); - sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); - sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); - sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); - sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); - sqlBuilder.append("tg.id as taxGroupId, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); - sqlBuilder.append("sp.id as productId, "); - sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); - sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); - sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); - sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); - sqlBuilder.append("sp.accounting_type as accountingType, "); - sqlBuilder.append("tr.id as transactionId, tr.transaction_type_enum as transactionType, "); - sqlBuilder.append("tr.transaction_date as transactionDate, tr.amount as transactionAmount,"); - sqlBuilder.append("tr.submitted_on_date as transSubmittedOnDate,tr.cumulative_balance_derived as cumulativeBalance,"); - sqlBuilder.append("tr.running_balance_derived as runningBalance, tr.is_reversed as reversed,"); - sqlBuilder.append("tr.balance_end_date_derived as balanceEndDate, tr.overdraft_amount_derived as overdraftAmount,"); - sqlBuilder.append("tr.is_manual as manualTransaction,tr.office_id as officeId, "); - sqlBuilder.append("pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, "); - sqlBuilder.append("pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, "); - sqlBuilder.append("pt.value as paymentTypeName, "); - sqlBuilder.append("msacpb.amount as paidByAmount, msacpb.id as chargesPaidById, "); - sqlBuilder.append( - "msac.id as chargeId, msac.amount as chargeAmount, msac.charge_time_enum as chargeTimeType, msac.is_penalty as isPenaltyCharge, "); - sqlBuilder.append("txd.id as taxDetailsId, txd.amount as taxAmount, "); - sqlBuilder.append("apm.gl_account_id as glAccountIdForInterestOnSavings, apm1.gl_account_id as glAccountIdForSavingsControl, "); - sqlBuilder.append( - "apm2.gl_account_id as glAccountIdForInterestReceivable,apm3.gl_account_id as glAccountIdForOverdraftPorfolio, "); - sqlBuilder.append("apm4.gl_account_id as glAccountIdForInterestPayable, "); - sqlBuilder.append( - "mtc.id as taxComponentId, mtc.debit_account_id as debitAccountId, mtc.credit_account_id as creditAccountId, mtc.percentage as taxPercentage "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - sqlBuilder.append("join m_savings_account_transaction tr on sa.id = tr.savings_account_id "); - sqlBuilder.append("left join m_payment_detail pd on pd.id = tr.payment_detail_id "); - sqlBuilder.append("left join m_payment_type pt on pt.id = pd.payment_type_id "); - sqlBuilder.append("left join m_savings_account_charge_paid_by msacpb on msacpb.savings_account_transaction_id = tr.id "); - sqlBuilder.append("left join m_savings_account_charge msac on msac.id = msacpb.savings_account_charge_id "); - sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); - sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); - sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); - sqlBuilder.append("left join m_savings_account_transaction_tax_details txd on txd.savings_transaction_id = tr.id "); - sqlBuilder.append("left join m_tax_component mtc on mtc.id = txd.tax_component_id "); - sqlBuilder.append( - "left join acc_product_mapping apm on apm.product_type = 2 and apm.product_id = sp.id and apm.financial_account_type=3 "); - sqlBuilder.append( - "left join acc_product_mapping apm1 on apm1.product_type = 2 and apm1.product_id = sp.id and apm1.financial_account_type=2 "); - sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); - sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); - sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); - // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a - // cumulative one: interest that is swept out to a linked account each month must never - // compound, and without this flag the engine compounded it. Left join because ordinary - // savings accounts have no term-and-preclosure row at all. - sqlBuilder.append( - "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public List extractData(final ResultSet rs) throws SQLException { - - List savingsAccountDataList = new ArrayList<>(); - HashMap savingsMap = new HashMap<>(); - String currencyCode = null; - Integer currencyDigits = null; - Integer inMultiplesOf = null; - CurrencyData currency = null; - HashMap transMap = new HashMap<>(); - HashMap taxDetails = new HashMap<>(); - HashMap chargeDetails = new HashMap<>(); - SavingsAccountTransactionData savingsAccountTransactionData = null; - SavingsAccountData savingsAccountData = null; - int count = 0; - - while (rs.next()) { - final Long id = rs.getLong("id"); - final Long transactionId = rs.getLong("transactionId"); - final Long taxDetailId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxDetailsId"); - final Long taxComponentId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxComponentId"); - final String accountNo = rs.getString("accountNo"); - final Long chargeId = rs.getLong("chargeId"); - - if (!savingsMap.containsValue(id)) { - if (count > 0) { - savingsAccountDataList.add(savingsAccountData); - } - count++; - savingsMap.put("id", id); - - final String externalId = rs.getString("externalId"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - final Long groupId = JdbcSupport.getLong(rs, "groupId"); - final Long groupOfficeId = JdbcSupport.getLong(rs, "groupOfficeId"); - final GroupGeneralData groupGeneralData = new GroupGeneralData(groupId, groupOfficeId); - - final Long clientId = JdbcSupport.getLong(rs, "clientId"); - final Long clientOfficeId = JdbcSupport.getLong(rs, "clientOfficeId"); - final ClientData clientData = ClientData.createClientForInterestPosting(clientId, clientOfficeId); - - final Long glAccountIdForInterestOnSavings = rs.getLong("glAccountIdForInterestOnSavings"); - final Long glAccountIdForSavingsControl = rs.getLong("glAccountIdForSavingsControl"); - - final Long glAccountIdForOverdraftPorfolio = rs.getLong("glAccountIdForOverdraftPorfolio"); - final Long glAccountIdForInterestReceivable = rs.getLong("glAccountIdForInterestReceivable"); - - final Long glAccountIdForInterestPayable = rs.getLong("glAccountIdForInterestPayable"); - - final Long productId = rs.getLong("productId"); - final Integer accountType = rs.getInt("accountingType"); - final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountType); - final EnumOptionData enumOptionDataForAccounting = new EnumOptionData(accountType.longValue(), - accountingRuleType.getCode(), accountingRuleType.getValue().toString()); - final SavingsProductData savingsProductData = SavingsProductData.createForInterestPosting(productId, - enumOptionDataForAccounting); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); - final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); - final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); - final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); - final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); - final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); - Integer daysToInactive = null; - Integer daysToDormancy = null; - Integer daysToEscheat = null; - - LocalDate currentDate = DateUtils.getBusinessLocalDate(); - if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { - if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { - daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToEscheat)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { - daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToDormancy)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { - daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToInactive)); - } - } - final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); - final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); - final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); - final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, null, - null, null, null, null, null, null, withdrawnOnDate, null, null, null, approvedOnDate, null, null, null, - activatedOnDate, null, null, null, closedOnDate, null, null, null); - - currencyCode = rs.getString("currencyCode"); - currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - currency = new CurrencyData(currencyCode, currencyDigits, inMultiplesOf); - - final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); - final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); - final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); - final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); - - final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); - final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); - final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); - final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); - final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); - final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, - "totalOverdraftInterestDerived"); - final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); - final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); - - final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minBalanceForInterestCalculation"); - final BigDecimal onHoldFunds = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldFunds"); - - final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldAmount"); - - BigDecimal availableBalance = accountBalance; - if (availableBalance != null && onHoldFunds != null) { - - availableBalance = availableBalance.subtract(onHoldFunds); - } - - if (availableBalance != null && onHoldAmount != null) { - - availableBalance = availableBalance.subtract(onHoldAmount); - } - - BigDecimal interestNotPosted = BigDecimal.ZERO; - LocalDate lastInterestCalculationDate = null; - if (totalInterestEarned != null) { - interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); - lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); - } - - final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, - totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, - totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, - lastInterestCalculationDate, availableBalance, interestPostedTillDate); - summary.setPrevInterestPostedTillDate(interestPostedTillDate); - - final boolean withHoldTax = rs.getBoolean("withHoldTax"); - final Long taxGroupId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxGroupId"); - TaxGroupData taxGroupData = null; - if (taxGroupId != null) { - taxGroupData = TaxGroupData.lookup(taxGroupId, null); - } - - final BigDecimal nominalAnnualInterestRate = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRate"); - - final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( - SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); - - final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( - SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); - - final EnumOptionData interestCalculationType = SavingsEnumerations.interestCalculationType( - SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); - - final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations - .interestCalculationDaysInYearType(SavingsInterestCalculationDaysInYearType - .fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); - - final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minRequiredOpeningBalance"); - - final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); - EnumOptionData lockinPeriodFrequencyType = null; - final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); - if (lockinPeriodFrequencyTypeValue != null) { - final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType - .fromInt(lockinPeriodFrequencyTypeValue); - lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); - } - - final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); - - final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); - final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); - final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRateOverdraft"); - final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minOverdraftForInterestCalculation"); - - final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); - final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); - final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); - final boolean lienAllowed = rs.getBoolean("lienAllowed"); - savingsAccountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, null, null, null, null, - productId, null, null, null, status, subStatus, null, timeline, currency, nominalAnnualInterestRate, - interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, - enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, - nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, - lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, - onHoldAmount); - - savingsAccountData.setClientData(clientData); - savingsAccountData.setGroupGeneralData(groupGeneralData); - savingsAccountData.setSavingsProduct(savingsProductData); - - savingsAccountData.setGlAccountIdForInterestReceivable(glAccountIdForInterestReceivable); - savingsAccountData.setGlAccountIdForOverdraftPorfolio(glAccountIdForOverdraftPorfolio); - - savingsAccountData.setGlAccountIdForInterestPayable(glAccountIdForInterestPayable); - - savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); - savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); - // Monthly-payout deposits must not compound: their interest leaves the account every - // period, so each period earns on the principal alone. - savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); - } - - if (!transMap.containsValue(transactionId)) { - - final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); - final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); - - final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); - final LocalDate balanceEndDate = JdbcSupport.getLocalDate(rs, "balanceEndDate"); - final LocalDate transSubmittedOnDate = JdbcSupport.getLocalDate(rs, "transSubmittedOnDate"); - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); - final BigDecimal overdraftAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "overdraftAmount"); - final BigDecimal outstandingChargeAmount = null; - final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); - final boolean reversed = rs.getBoolean("reversed"); - final Long officeId = rs.getLong("officeId"); - final BigDecimal cumulativeBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "cumulativeBalance"); - - final boolean postInterestAsOn = false; - - PaymentDetailData paymentDetailData = null; - if (transactionType.isDepositOrWithdrawal()) { - final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); - if (paymentTypeId != null) { - final String typeName = rs.getString("paymentTypeName"); - final PaymentTypeData paymentTypeData = new PaymentTypeData(paymentTypeId, typeName, null, false, null, null, - false); - paymentDetailData = new PaymentDetailData(id, paymentTypeData, null, null, null, null, null); - } - } - - savingsAccountTransactionData = SavingsAccountTransactionData.create(transactionId, transactionType, paymentDetailData, - id, accountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transSubmittedOnDate, - postInterestAsOn, cumulativeBalance, balanceEndDate); - savingsAccountTransactionData.setOverdraftAmount(overdraftAmount); - - transMap.put("id", transactionId); - if (savingsAccountData.getOfficeId() == null) { - savingsAccountData.setOfficeId(officeId); - } - - savingsAccountData.setSavingsAccountTransactionData(savingsAccountTransactionData); - } - - if (chargeId != null && !chargeDetails.containsValue(chargeId)) { - final boolean isPenalty = rs.getBoolean("isPenaltyCharge"); - final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeAmount"); - final Integer chargesTimeType = rs.getInt("chargeTimeType"); - final EnumOptionData enumOptionDataForChargesTimeType = new EnumOptionData(chargesTimeType.longValue(), null, null); - final SavingsAccountChargeData savingsAccountChargeData = new SavingsAccountChargeData(chargeId, chargeAmount, - enumOptionDataForChargesTimeType, isPenalty); - - final Long chargesPaidById = rs.getLong("chargesPaidById"); - final BigDecimal chargesPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "paidByAmount"); - final SavingsAccountChargesPaidByData savingsAccountChargesPaidByData = new SavingsAccountChargesPaidByData( - chargesPaidById, chargesPaid); - savingsAccountChargesPaidByData.setSavingsAccountChargeData(savingsAccountChargeData); - if (savingsAccountChargesPaidByData != null) { - savingsAccountTransactionData.setChargesPaidByData(savingsAccountChargesPaidByData); - } - - chargeDetails.put("id", chargeId); - } - - if (taxDetailId != null && !taxDetails.containsValue(taxDetailId)) { - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxAmount"); - final BigDecimal percentage = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxPercentage"); - final Long debitId = rs.getLong("debitAccountId"); - final Long creditId = rs.getLong("creditAccountId"); - final GLAccountData debitAccount = GLAccountData.createFrom(debitId); - final GLAccountData creditAccount = GLAccountData.createFrom(creditId); - - if (taxComponentId != null) { - final TaxComponentData taxComponent = TaxComponentData.createTaxComponent(taxComponentId, percentage, debitAccount, - creditAccount); - savingsAccountTransactionData.setTaxDetails(new TaxDetailsData(taxComponent, amount)); - } - - taxDetails.put("id", taxDetailId); - } - - } - if (savingsAccountData != null) { - savingsAccountDataList.add(savingsAccountData); - } - return savingsAccountDataList; - - } - } - - private static final class SavingAccountMapper implements RowMapper { - - private final String schemaSql; - - SavingAccountMapper() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("c.id as clientId, c.display_name as clientName, "); - sqlBuilder.append("g.id as groupId, g.display_name as groupName, "); - sqlBuilder.append("sp.id as productId, sp.name as productName, "); - sqlBuilder.append("s.id fieldOfficerId, s.display_name as fieldOfficerName, "); - sqlBuilder.append("sa.status_enum as statusEnum, "); - sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); - sqlBuilder.append("sa.reason_for_block as reasonForBlock, "); - sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); - sqlBuilder.append("sbu.username as submittedByUsername,"); - sqlBuilder.append("sbu.firstname as submittedByFirstname, sbu.lastname as submittedByLastname,"); - - sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); - sqlBuilder.append("rbu.username as rejectedByUsername,"); - sqlBuilder.append("rbu.firstname as rejectedByFirstname, rbu.lastname as rejectedByLastname,"); - - sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); - sqlBuilder.append("wbu.username as withdrawnByUsername,"); - sqlBuilder.append("wbu.firstname as withdrawnByFirstname, wbu.lastname as withdrawnByLastname,"); - - sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); - sqlBuilder.append("abu.username as approvedByUsername,"); - sqlBuilder.append("abu.firstname as approvedByFirstname, abu.lastname as approvedByLastname,"); - - sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); - sqlBuilder.append("avbu.username as activatedByUsername,"); - sqlBuilder.append("avbu.firstname as activatedByFirstname, avbu.lastname as activatedByLastname,"); - - sqlBuilder.append("sa.closedon_date as closedOnDate,"); - sqlBuilder.append("cbu.username as closedByUsername,"); - sqlBuilder.append("cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname,"); - - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); - sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); - - sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); - sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); - sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); - sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); - sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); - sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); - sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); - - sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); - sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); - sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); - sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); - - sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); - sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); - sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); - sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); - sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); - sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); - sqlBuilder.append("sa.account_balance_derived as accountBalance, "); - sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); - sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); - sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); - sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); - sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); - sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); - sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); - sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); - sqlBuilder.append("sa.withhold_tax as withHoldTax, "); - sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); - sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); - sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); - sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); - sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); - sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); - sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); - sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); - sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); - sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); - // CBS extended fields - sqlBuilder.append("sa.cbs_account_type as cbsAccountType, "); - sqlBuilder.append("sa.customer_segment as customerSegment, "); - sqlBuilder.append("sa.account_status as accountStatus, "); - sqlBuilder.append("sa.mode_of_operation as modeOfOperation, "); - sqlBuilder.append("sa.special_category as specialCategory, "); - sqlBuilder.append("sa.weaker_section as weakerSection, "); - sqlBuilder.append("sa.guardian_name as guardianName, "); - sqlBuilder.append("sa.guardian_cif_number as guardianCifNumber, "); - sqlBuilder.append("sa.guardian_relationship as guardianRelationship, "); - sqlBuilder.append("sa.minor_date_of_birth as minorDateOfBirth, "); - sqlBuilder.append("sa.guardian_client_id as guardianClientId, "); - sqlBuilder.append("sa.introducer_client_id as introducerClientId, "); - sqlBuilder.append("sa.introducer_name as introducerName, "); - sqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, "); - sqlBuilder.append("sa.nominee_data as nomineeData, "); - sqlBuilder.append("sa.linked_account_id as linkedAccountId, "); - sqlBuilder.append("sa.linked_account_type as linkedAccountType, "); - sqlBuilder.append("sa.linked_account_no as linkedAccountNo, "); - sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, "); - sqlBuilder.append("sp.msme as msme, "); - sqlBuilder.append("COALESCE(sa.nomination_facility, sp.nomination_facility) as nominationFacility, "); - sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, "); - sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, "); - sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, "); - sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, "); - // Facility config fields - sqlBuilder.append("safc.atm_facility as atmFacility, "); - sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, "); - sqlBuilder.append("safc.upi_facility as upiFacility, "); - sqlBuilder.append("safc.pos_transactions_facility as posTransactionsFacility, "); - sqlBuilder.append("safc.cheque_book as chequeBook, "); - sqlBuilder.append("safc.imps_facility as impsFacility, "); - sqlBuilder.append("safc.sms_alerts as smsAlerts, "); - sqlBuilder.append("safc.bbps_facility as bbpsFacility, "); - sqlBuilder.append("safc.insurance_cover as insuranceCover, "); - sqlBuilder.append("safc.bepg_facility as bepgFacility, "); - sqlBuilder.append("safc.neft_facility as neftFacility, "); - sqlBuilder.append("safc.rtgs_facility as rtgsFacility, "); - sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, "); - sqlBuilder.append("safc.withdrawal_facility as withdrawalFacility, "); - sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, "); - sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, "); - sqlBuilder.append("sa.terminate_true as terminateTrue "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); - sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); - sqlBuilder.append("left join m_staff s ON s.id = sa.field_officer_id "); - sqlBuilder.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); - sqlBuilder.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); - sqlBuilder.append("left join m_appuser wbu on wbu.id = sa.withdrawnon_userid "); - sqlBuilder.append("left join m_appuser abu on abu.id = sa.approvedon_userid "); - sqlBuilder.append("left join m_appuser avbu on avbu.id = sa.activatedon_userid "); - sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid "); - sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); - sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id "); - sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long id = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - final String externalId = rs.getString("externalId"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - - final Long groupId = JdbcSupport.getLong(rs, "groupId"); - final String groupName = rs.getString("groupName"); - - final Long clientId = JdbcSupport.getLong(rs, "clientId"); - final String clientName = rs.getString("clientName"); - - final Long productId = rs.getLong("productId"); - final String productName = rs.getString("productName"); - - final Long fieldOfficerId = rs.getLong("fieldOfficerId"); - final String fieldOfficerName = rs.getString("fieldOfficerName"); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); - final String reasonForBlock = rs.getString("reasonForBlock"); - - final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); - final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); - final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); - final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); - final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); - Integer daysToInactive = null; - Integer daysToDormancy = null; - Integer daysToEscheat = null; - - LocalDate currentDate = DateUtils.getBusinessLocalDate(); - if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { - if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { - daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { - daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToDormancy)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { - daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); - } - } - - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final String submittedByUsername = rs.getString("submittedByUsername"); - final String submittedByFirstname = rs.getString("submittedByFirstname"); - final String submittedByLastname = rs.getString("submittedByLastname"); - - final LocalDate rejectedOnDate = JdbcSupport.getLocalDate(rs, "rejectedOnDate"); - final String rejectedByUsername = rs.getString("rejectedByUsername"); - final String rejectedByFirstname = rs.getString("rejectedByFirstname"); - final String rejectedByLastname = rs.getString("rejectedByLastname"); - - final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); - final String withdrawnByUsername = rs.getString("withdrawnByUsername"); - final String withdrawnByFirstname = rs.getString("withdrawnByFirstname"); - final String withdrawnByLastname = rs.getString("withdrawnByLastname"); - - final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); - final String approvedByUsername = rs.getString("approvedByUsername"); - final String approvedByFirstname = rs.getString("approvedByFirstname"); - final String approvedByLastname = rs.getString("approvedByLastname"); - - final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); - final String activatedByUsername = rs.getString("activatedByUsername"); - final String activatedByFirstname = rs.getString("activatedByFirstname"); - final String activatedByLastname = rs.getString("activatedByLastname"); - - final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); - final String closedByUsername = rs.getString("closedByUsername"); - final String closedByFirstname = rs.getString("closedByFirstname"); - final String closedByLastname = rs.getString("closedByLastname"); - - final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, - submittedByUsername, submittedByFirstname, submittedByLastname, rejectedOnDate, rejectedByUsername, rejectedByFirstname, - rejectedByLastname, withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, - approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, - activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - final BigDecimal nominalAnnualInterestRate = rs.getBigDecimal("nominalAnnualInterestRate"); - - final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( - SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); - - final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( - SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); - - final EnumOptionData interestCalculationType = SavingsEnumerations - .interestCalculationType(SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); - - final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations.interestCalculationDaysInYearType( - SavingsInterestCalculationDaysInYearType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); - - final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredOpeningBalance"); - - final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); - EnumOptionData lockinPeriodFrequencyType = null; - final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); - if (lockinPeriodFrequencyTypeValue != null) { - final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType.fromInt(lockinPeriodFrequencyTypeValue); - lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); - } - - final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); - - final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); - final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); - final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRateOverdraft"); - final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minOverdraftForInterestCalculation"); - - final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); - final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); - - final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); - final boolean lienAllowed = rs.getBoolean("lienAllowed"); - - final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); - final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); - final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); - final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); - - final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); - final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); - final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); - final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); - final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); - final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, - "totalOverdraftInterestDerived"); - final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); - final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); - - final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minBalanceForInterestCalculation"); - final BigDecimal onHoldFunds = rs.getBigDecimal("onHoldFunds"); - - final BigDecimal onHoldAmount = rs.getBigDecimal("onHoldAmount"); - - BigDecimal availableBalance = accountBalance; - if (availableBalance != null && onHoldFunds != null) { - availableBalance = availableBalance.subtract(onHoldFunds); - } - - if (availableBalance != null && onHoldAmount != null) { - - availableBalance = availableBalance.subtract(onHoldAmount); - } - - BigDecimal interestNotPosted = BigDecimal.ZERO; - LocalDate lastInterestCalculationDate = null; - if (totalInterestEarned != null) { - interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); - lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); - } - - final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, - totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, - totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, lastInterestCalculationDate, - availableBalance, interestPostedTillDate); - - final boolean withHoldTax = rs.getBoolean("withHoldTax"); - final Long taxGroupId = JdbcSupport.getLong(rs, "taxGroupId"); - final String taxGroupName = rs.getString("taxGroupName"); - TaxGroupData taxGroupData = null; - if (taxGroupId != null) { - taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); - } - - final SavingsAccountData accountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, groupId, groupName, - clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, subStatus, reasonForBlock, - timeline, currency, nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, - interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, - lockinPeriodFrequencyType, withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, - enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, - nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, - lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, onHoldAmount); - - // CBS extended fields - accountData.setCbsAccountType(rs.getString("cbsAccountType")); - accountData.setCustomerSegment(rs.getString("customerSegment")); - accountData.setAccountStatus(rs.getString("accountStatus")); - accountData.setModeOfOperation(rs.getString("modeOfOperation")); - accountData.setSpecialCategory(rs.getString("specialCategory")); - accountData.setWeakerSection(rs.getString("weakerSection")); - accountData.setGuardianName(rs.getString("guardianName")); - accountData.setGuardianCifNumber(rs.getString("guardianCifNumber")); - accountData.setGuardianRelationship(rs.getString("guardianRelationship")); - accountData.setMinorDateOfBirth(JdbcSupport.getLocalDate(rs, "minorDateOfBirth")); - accountData.setGuardianClientId(JdbcSupport.getLong(rs, "guardianClientId")); - accountData.setIntroducerClientId(JdbcSupport.getLong(rs, "introducerClientId")); - accountData.setIntroducerName(rs.getString("introducerName")); - accountData.setIntroducerCifNumber(rs.getString("introducerCifNumber")); - accountData.setLinkedAccountId(JdbcSupport.getLong(rs, "linkedAccountId")); - accountData.setLinkedAccountType(rs.getString("linkedAccountType")); - accountData.setLinkedAccountNo(rs.getString("linkedAccountNo")); - accountData.setBalanceSwapOption(rs.getObject("balanceSwapOption") != null ? rs.getBoolean("balanceSwapOption") : null); - accountData.setMsme(rs.getObject("msme") != null ? rs.getBoolean("msme") : null); - accountData.setNominationFacility(rs.getObject("nominationFacility") != null ? rs.getBoolean("nominationFacility") : null); - accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null); - accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null); - accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null); - accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId")); - accountData.setShareAccountNo(rs.getString("shareAccountNo")); - accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null); - - // Nominee as parsed map - final String nomineeDataStr = rs.getString("nomineeData"); - if (nomineeDataStr != null && !nomineeDataStr.isEmpty()) { - try { - com.google.gson.Gson gson = new com.google.gson.Gson(); - @SuppressWarnings("unchecked") - java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, - new com.google.gson.reflect.TypeToken>() {}.getType()); - accountData.setNomineeData(nomineeMap); - } catch (Exception ignored) { - // intentionally ignored - } - } - - // Facility config - final boolean hasFacilityConfig = rs.getObject("atmFacility") != null; - if (hasFacilityConfig) { - final java.util.Map facilityConfigMap = new java.util.LinkedHashMap<>(); - facilityConfigMap.put("atmFacility", rs.getBoolean("atmFacility")); - facilityConfigMap.put("atmWithdrawalFacility", rs.getBoolean("atmWithdrawalFacility")); - facilityConfigMap.put("upiFacility", rs.getBoolean("upiFacility")); - facilityConfigMap.put("posTransactionsFacility", rs.getBoolean("posTransactionsFacility")); - facilityConfigMap.put("chequeBook", rs.getBoolean("chequeBook")); - facilityConfigMap.put("impsFacility", rs.getBoolean("impsFacility")); - facilityConfigMap.put("smsAlerts", rs.getBoolean("smsAlerts")); - facilityConfigMap.put("bbpsFacility", rs.getBoolean("bbpsFacility")); - facilityConfigMap.put("insuranceCover", rs.getBoolean("insuranceCover")); - facilityConfigMap.put("bepgFacility", rs.getBoolean("bepgFacility")); - facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility")); - facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility")); - facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility")); - facilityConfigMap.put("withdrawalFacility", rs.getBoolean("withdrawalFacility")); - facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc")); - facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard")); - accountData.setAccountFacilityConfig(facilityConfigMap); - } - - return accountData; - } - } - - private static final class SavingAccountMapperForLookup implements RowMapper { - - private final String schemaSql; - - SavingAccountMapperForLookup() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("sp.id as productId, sp.name as productName, "); - sqlBuilder.append("sa.status_enum as statusEnum "); - - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long id = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - - final Long productId = rs.getLong("productId"); - final String productName = rs.getString("productName"); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - - return SavingsAccountData.lookupWithProductDetails(id, accountNo, depositType, productId, productName, status); - } - } - - @Override - public SavingsAccountTransactionData retrieveDepositTransactionTemplate(final Long savingsId, - final DepositAccountType depositAccountType) { - - try { - final String sql = "select " + this.transactionTemplateMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ?"; - - return this.jdbcTemplate.queryForObject(sql, this.transactionTemplateMapper, // NOSONAR - new Object[] { savingsId, depositAccountType.getValue() }); - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(savingsId, e); - } - } - - @Override - public Collection retrieveAllTransactions(final Long savingsId, DepositAccountType depositAccountType) { - // #audit backward compatibility - final String sql = "select " + this.transactionsMapper.schema() - + " where sa.id = ? and sa.deposit_type_enum = ? order by tr.transaction_date DESC," + " tr." + CREATED_DATE_DB_FIELD - + " DESC, tr.created_date DESC, tr.id DESC"; - return this.jdbcTemplate.query(sql, this.transactionsMapper, new Object[] { savingsId, depositAccountType.getValue() }); // NOSONAR - } - - @Override - public SavingsAccountTransactionData retrieveSavingsTransaction(final Long savingsId, final Long transactionId, - DepositAccountType depositAccountType) { - - final String sql = "select " + this.transactionsMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ? and tr.id= ?"; - - return this.jdbcTemplate.queryForObject(sql, this.transactionsMapper, // NOSONAR - new Object[] { savingsId, depositAccountType.getValue(), transactionId }); - } - - private static final class SavingsAccountTransactionsForBatchMapper implements RowMapper { - - private final String schemaSql; - - SavingsAccountTransactionsForBatchMapper() { - - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("tr.id as transactionId, tr.ref_no as refNo "); - sqlBuilder.append("from m_savings_account_transaction tr"); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - final Long id = rs.getLong("transactionId"); - SavingsAccountTransactionData savingsAccountTransactionData = SavingsAccountTransactionData.create(id); - final String refNo = rs.getString("refNo"); - savingsAccountTransactionData.setRefNo(refNo); - return savingsAccountTransactionData; - } - } - - /* - * @Override public Collection retrieveAccountsWithAnnualFeeDue() { final String sql = - * "select " + this.annualFeeMapper.schema() + - * " where sa.annual_fee_next_due_date is not null and sa.annual_fee_next_due_date <= NOW()" ; - * - * return this.jdbcTemplate.query(sql, this.annualFeeMapper, new Object[] {}); } - */ - - public static class SavingsAccountTransactionsMapper implements RowMapper { - - private static final String SELECT = buildSelect(); - private static final String FROM = buildFrom(); - private static final String SCHEMA = SELECT + FROM; - - public SavingsAccountTransactionsMapper() {} - - protected static String buildSelect() { - return "tr.id as transactionId, tr.transaction_type_enum as transactionType, " - + "tr.transaction_date as transactionDate, tr.amount as transactionAmount, " - + "tr.release_id_of_hold_amount as releaseTransactionId, tr.reason_for_block as reasonForBlock, " - + "tr.submitted_on_date as submittedOnDate, au.username as submittedByUsername, nt.note as transactionNote, " - + "tr.running_balance_derived as runningBalance, tr.is_reversed as reversed, " - + "tr.is_reversal as isReversal, tr.original_transaction_id as originalTransactionId, tr.is_lien_transaction as lienTransaction, " - + "fromtran.id as fromTransferId, fromtran.is_reversed as fromTransferReversed, " - + "fromtran.transaction_date as fromTransferDate, fromtran.amount as fromTransferAmount, " - + "fromtran.description as fromTransferDescription, " - + "totran.id as toTransferId, totran.is_reversed as toTransferReversed, " - + "totran.transaction_date as toTransferDate, totran.amount as toTransferAmount, " - + "totran.description as toTransferDescription, sa.id as savingsId, sa.account_no as accountNo, " - + "pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, " - + "pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, " - + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, " - + "curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, " - + "curr.display_symbol as currencyDisplaySymbol, pt.value as paymentTypeName, " + "tr.is_manual as postInterestAsOn "; - } - - protected static String buildFrom() { - return " FROM m_savings_account_transaction tr join m_savings_account sa on tr.savings_account_id = sa.id " - + "join m_currency curr on curr.code = sa.currency_code " - + "left join m_account_transfer_transaction fromtran on fromtran.from_savings_transaction_id = tr.id " - + "left join m_account_transfer_transaction totran on totran.to_savings_transaction_id = tr.id " - + "left join m_payment_detail pd on tr.payment_detail_id = pd.id " - + "left join m_payment_type pt on pd.payment_type_id = pt.id left join m_appuser au on au.id= tr." + CREATED_BY_DB_FIELD - + " left join m_note nt ON nt.savings_account_transaction_id=tr.id "; - } - - public String schema() { - return SCHEMA; - } - - public String select() { - return SELECT; - } - - public String from() { - return FROM; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - final Long id = rs.getLong("transactionId"); - final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); - final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); - - final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); - final Long releaseTransactionId = rs.getLong("releaseTransactionId"); - final String reasonForBlock = rs.getString("reasonForBlock"); - final BigDecimal outstandingChargeAmount = null; - final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); - final boolean reversed = rs.getBoolean("reversed"); - final boolean isReversal = rs.getBoolean("isReversal"); - final Long originalTransactionId = rs.getLong("originalTransactionId"); - final Boolean lienTransaction = rs.getBoolean("lienTransaction"); - - final Long savingsId = rs.getLong("savingsId"); - final String accountNo = rs.getString("accountNo"); - final boolean postInterestAsOn = rs.getBoolean("postInterestAsOn"); - - PaymentDetailData paymentDetailData = null; - if (transactionType.isDepositOrWithdrawal()) { - final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); - if (paymentTypeId != null) { - final String typeName = rs.getString("paymentTypeName"); - final PaymentTypeData paymentType = PaymentTypeData.builder().id(paymentTypeId).name(typeName).build(); - final String accountNumber = rs.getString("accountNumber"); - final String checkNumber = rs.getString("checkNumber"); - final String routingCode = rs.getString("routingCode"); - final String receiptNumber = rs.getString("receiptNumber"); - final String bankNumber = rs.getString("bankNumber"); - paymentDetailData = new PaymentDetailData(id, paymentType, accountNumber, checkNumber, routingCode, receiptNumber, - bankNumber); - } - } - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - AccountTransferData transfer = null; - final Long fromTransferId = JdbcSupport.getLong(rs, "fromTransferId"); - final Long toTransferId = JdbcSupport.getLong(rs, "toTransferId"); - if (fromTransferId != null) { - final LocalDate fromTransferDate = JdbcSupport.getLocalDate(rs, "fromTransferDate"); - final BigDecimal fromTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "fromTransferAmount"); - final boolean fromTransferReversed = rs.getBoolean("fromTransferReversed"); - final String fromTransferDescription = rs.getString("fromTransferDescription"); - - transfer = AccountTransferData.transferBasicDetails(fromTransferId, currency, fromTransferAmount, fromTransferDate, - fromTransferDescription, fromTransferReversed); - } else if (toTransferId != null) { - final LocalDate toTransferDate = JdbcSupport.getLocalDate(rs, "toTransferDate"); - final BigDecimal toTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "toTransferAmount"); - final boolean toTransferReversed = rs.getBoolean("toTransferReversed"); - final String toTransferDescription = rs.getString("toTransferDescription"); - - transfer = AccountTransferData.transferBasicDetails(toTransferId, currency, toTransferAmount, toTransferDate, - toTransferDescription, toTransferReversed); - } - final String submittedByUsername = rs.getString("submittedByUsername"); - final String note = rs.getString("transactionNote"); - return SavingsAccountTransactionData.create(id, transactionType, paymentDetailData, savingsId, accountNo, date, currency, - amount, outstandingChargeAmount, runningBalance, reversed, transfer, submittedOnDate, postInterestAsOn, - submittedByUsername, note, isReversal, originalTransactionId, lienTransaction, releaseTransactionId, reasonForBlock); - } - } - - private static final class SavingsAccountTransactionTemplateMapper implements RowMapper { - - private final String schemaSql; - - SavingsAccountTransactionTemplateMapper() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); - sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long savingsId = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - return SavingsAccountTransactionData.template(savingsId, accountNo, DateUtils.getBusinessLocalDate(), currency); - } - } - - @Override - public Collection retrieveForLookup(Long clientId, Boolean overdraft) { - - SavingAccountMapperForLookup accountMapperForLookup = new SavingAccountMapperForLookup(); - final StringBuilder sqlBuilder = new StringBuilder("select " + accountMapperForLookup.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300"); - Object[] queryParameters = null; - if (overdraft == null) { - queryParameters = new Object[] { clientId }; - } else { - sqlBuilder.append(" and sa.allow_overdraft = ?"); - queryParameters = new Object[] { clientId, overdraft }; - } - return this.jdbcTemplate.query(sqlBuilder.toString(), accountMapperForLookup, queryParameters); - - } - - @Override - public List retrieveSavingsIdsPendingInactive(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 0 "); - String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " - + "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false" - + " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)"; - sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public List retrieveSavingsIdsPendingDormant(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 100 "); - sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") - + " "); - sql.append(" >= sp.days_to_dormancy "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public List retrieveSavingsIdsPendingEscheat(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 200 "); - sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") - + " "); - sql.append(" >= sp.days_to_escheat "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), Long.class, new Object[] { tenantLocalDate }); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public boolean isAccountBelongsToClient(final Long clientId, final Long accountId, final DepositAccountType depositAccountType, - final String currencyCode) { - try { - final StringBuilder buff = new StringBuilder("select count(*) from m_savings_account sa "); - buff.append( - " where sa.id = ? and sa.client_id = ? and sa.deposit_type_enum = ? and sa.currency_code = ? and sa.status_enum = 300"); - return this.jdbcTemplate.queryForObject(buff.toString(), Integer.class, accountId, clientId, depositAccountType.getValue(), - currencyCode) > 0; - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - /* - * private static final class SavingsAccountAnnualFeeMapper implements RowMapper { - * - * private final String schemaSql; - * - * public SavingsAccountAnnualFeeMapper() { final StringBuilder sqlBuilder = new StringBuilder(200); - * sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); sqlBuilder - * .append("sa.annual_fee_next_due_date as annualFeeNextDueDate "); sqlBuilder.append("from m_savings_account sa "); - * - * this.schemaSql = sqlBuilder.toString(); } - * - * public String schema() { return this.schemaSql; } - * - * @Override public SavingsAccountAnnualFeeData mapRow(final ResultSet rs, - * - * @SuppressWarnings("unused") final int rowNum) throws SQLException { - * - * final Long id = rs.getLong("id"); final String accountNo = rs.getString("accountNo"); final LocalDate - * annualFeeNextDueDate = JdbcSupport.getLocalDate(rs, "annualFeeNextDueDate"); - * - * return SavingsAccountAnnualFeeData.instance(id, accountNo, annualFeeNextDueDate); } } - */ - - @Override - public String retrieveAccountNumberByAccountId(Long accountId) { - try { - final String sql = "select s.account_no from m_savings_account s where s.id = ?"; - return this.jdbcTemplate.queryForObject(sql, String.class, accountId); - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - - @Override - public List getAccountsIdsByStatusPaged(Integer status, int pageSize, Long maxSavingsIdInList) { - String sql = new StringBuilder().append(" SELECT sa.id FROM m_savings_account sa ") - .append(" where sa.id > ? and sa.status_enum = ? ").append(" order by sa.id limit ?").toString(); - - try { - return this.jdbcTemplate.queryForList(sql, Long.class, new Object[] { maxSavingsIdInList, status, pageSize }); - } catch (EmptyResultDataAccessException e) { - return new ArrayList<>(); - } - } - - @Override - public Long retrieveAccountIdByExternalId(final ExternalId externalId) { - return savingsAccountRepositoryWrapper.findIdByExternalId(externalId); - } - - @Override - public List retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings) { - Long savingsId = (savings != null) ? savings.getId() : null; - Integer status = SavingsAccountStatusType.ACTIVE.getValue(); - Integer accountingRule = AccountingRuleType.ACCRUAL_PERIODIC.getValue(); - - return this.savingsAccountRepositoryWrapper.findAccrualData(tillDate, savingsId, status, accountingRule); - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_BY_DB_FIELD; +import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.accounting.common.AccountingRuleType; +import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.account.data.AccountTransferData; +import org.apache.fineract.portfolio.client.data.ClientData; +import org.apache.fineract.portfolio.group.data.GroupGeneralData; +import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimelineData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; +import org.apache.fineract.portfolio.savings.data.SavingsProductData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.tax.data.TaxComponentData; +import org.apache.fineract.portfolio.tax.data.TaxDetailsData; +import org.apache.fineract.portfolio.tax.data.TaxGroupData; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; + +public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + + // mappers + private final SavingsAccountTransactionTemplateMapper transactionTemplateMapper; + protected SavingsAccountTransactionsMapper transactionsMapper; + private final SavingsAccountTransactionsForBatchMapper savingsAccountTransactionsForBatchMapper; + private final SavingAccountMapper savingAccountMapper; + private final SavingAccountMapperForInterestPosting savingAccountMapperForInterestPosting; + // private final SavingsAccountAnnualFeeMapper annualFeeMapper; + + // pagination + private final PaginationHelper paginationHelper; + + private final ColumnValidator columnValidator; + private final SavingsAccountAssembler savingAccountAssembler; + + private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; + + public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, + final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { + this.context = context; + this.jdbcTemplate = jdbcTemplate; + this.sqlGenerator = sqlGenerator; + this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; + this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); + this.transactionsMapper = new SavingsAccountTransactionsMapper(); + this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); + this.savingAccountMapper = new SavingAccountMapper(); + this.columnValidator = columnValidator; + this.paginationHelper = paginationHelper; + this.savingAccountMapperForInterestPosting = new SavingAccountMapperForInterestPosting(); + this.savingAccountAssembler = savingAccountAssembler; + } + + @Override + public Collection retrieveAllForLookup(final Long clientId) { + + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 "); + + final Object[] queryParameters = new Object[] { clientId }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType) { + + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? "); + + final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue() }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType, + String currencyCode) { + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? and sa.currency_code = ? "); + + final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue(), currencyCode }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Page retrieveAll(final SearchParameters searchParameters) { + + final AppUser currentUser = this.context.authenticatedUser(); + final String hierarchy = currentUser.getOffice().getHierarchy(); + final String hierarchySearchString = hierarchy + "%"; + + final StringBuilder sqlBuilder = new StringBuilder(200); + sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); + sqlBuilder.append(this.savingAccountMapper.schema()); + + sqlBuilder.append(" join m_office o on o.id = c.office_id"); + sqlBuilder.append(" where o.hierarchy like ?"); + + final Object[] objectArray = new Object[15]; + objectArray[0] = hierarchySearchString; + int arrayPos = 1; + if (searchParameters != null) { + + if (StringUtils.isNotBlank(searchParameters.getStatus())) { + if ("terminated".equalsIgnoreCase(searchParameters.getStatus())) { + sqlBuilder.append(" and sa.terminate_true = true"); + } else { + final Integer statusEnum = resolveStatusEnum(searchParameters.getStatus()); + if (statusEnum != null) { + sqlBuilder.append(" and sa.status_enum = ?"); + objectArray[arrayPos++] = statusEnum; + if (statusEnum == 100) { + sqlBuilder.append(" and (sa.terminate_true = false or sa.terminate_true is null)"); + } + } + } + } + + if (StringUtils.isNotBlank(searchParameters.getExternalId())) { + sqlBuilder.append(" and sa.external_id = ?"); + objectArray[arrayPos] = searchParameters.getExternalId(); + arrayPos = arrayPos + 1; + } + if (searchParameters.getOfficeId() != null) { + sqlBuilder.append(" and c.office_id = ?"); + objectArray[arrayPos++] = searchParameters.getOfficeId(); + } + if (StringUtils.isNotBlank(searchParameters.getAccountNo())) { + sqlBuilder.append(" and sa.account_no like ?"); + objectArray[arrayPos++] = "%" + searchParameters.getAccountNo() + "%"; + } + if (StringUtils.isNotBlank(searchParameters.getName())) { + sqlBuilder.append(" and c.display_name like ?"); + objectArray[arrayPos++] = "%" + searchParameters.getName() + "%"; + } + if (searchParameters.getDepositType() != null) { + sqlBuilder.append(" and sa.deposit_type_enum = ?"); + objectArray[arrayPos++] = searchParameters.getDepositType(); + } + if (searchParameters.hasFromDate()) { + sqlBuilder.append(" and sa.activatedon_date >= ?"); + objectArray[arrayPos++] = searchParameters.getFromDate(); + } + if (searchParameters.hasToDate()) { + sqlBuilder.append(" and sa.activatedon_date <= ?"); + objectArray[arrayPos++] = searchParameters.getToDate(); + } + if (searchParameters.hasAsOnDate()) { + sqlBuilder.append(" and sa.activatedon_date <= ?"); + objectArray[arrayPos++] = searchParameters.getAsOnDate(); + } + if (searchParameters.hasOrderBy()) { + sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); + this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); + + if (searchParameters.hasSortOrder()) { + sqlBuilder.append(' ').append(searchParameters.getSortOrder()); + this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getSortOrder()); + } + } else { + sqlBuilder.append(" order by sa.id desc"); + } + + if (searchParameters.hasLimit()) { + sqlBuilder.append(" "); + if (searchParameters.hasOffset()) { + sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit(), searchParameters.getOffset())); + } else { + sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit())); + } + } + } + final Object[] finalObjectArray = Arrays.copyOf(objectArray, arrayPos); + return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), finalObjectArray, this.savingAccountMapper); + } + + private Integer resolveStatusEnum(final String status) { + if (status == null) { + return null; + } + return switch (status.toLowerCase()) { + case "active" -> 300; + case "approved" -> 200; + case "submitted", "submittedandpendingapproval" -> 100; + case "withdrawn" -> 400; + case "rejected" -> 500; + case "closed" -> 600; + case "terminated" -> 900; + default -> { + try { + yield Integer.parseInt(status); + } catch (NumberFormatException e) { + yield null; + } + } + }; + } + + @Override + public SavingsAccountData retrieveOne(final Long accountId) { + + try { + final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; + + final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper, + new Object[] { accountId }); // NOSONAR + + // Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL) + if (accountData != null) { + final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " + + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " + + "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? " + + "ORDER BY jh.is_primary DESC, jh.id ASC"; + final List> jointHolders = this.jdbcTemplate.query(jointSql, (rs, rowNum) -> { + final java.util.Map h = new java.util.LinkedHashMap<>(); + h.put("id", rs.getLong("id")); + h.put("clientId", rs.getLong("client_id")); + h.put("cifNumber", rs.getString("cif_number")); + h.put("displayName", rs.getString("display_name")); + h.put("sharePercentage", rs.getBigDecimal("share_percentage")); + h.put("signingAuthority", rs.getString("signing_authority")); + h.put("role", rs.getString("role")); + h.put("isPrimary", rs.getBoolean("is_primary")); + h.put("isMandatory", rs.getBoolean("is_mandatory")); + return h; + }, accountId); + if (!jointHolders.isEmpty()) { + accountData.setJointHolders(jointHolders); + } + } + + return accountData; + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + + @Override + public List retrieveAllTransactionData(final List refNo) throws DataAccessException { + String inSql = String.join(",", Collections.nCopies(refNo.size(), "?")); + String sql = "select " + this.savingsAccountTransactionsForBatchMapper.schema() + " where tr.ref_no in (%s)"; + Object[] params = new Object[refNo.size()]; + int i = 0; + for (String element : refNo) { + params[i] = element; + i++; + } + return this.jdbcTemplate.query(String.format(sql, inSql), this.savingsAccountTransactionsForBatchMapper, params); + } + + @Override + public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, + final Integer status, final Long maxSavingsId) { + LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); + String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; + if (backdatedTxnsAllowedTill) { + sql = sql + + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; + } + + sql = sql + " and (sa.interest_posted_till_date is null or sa.interest_posted_till_date <= ? ) "; + // #audit backward compatibility + sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; + + List savingsAccountDataList = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR + new Object[] { maxSavingsId, status, pageSize, yesterday }); + for (SavingsAccountData savingsAccountData : savingsAccountDataList) { + this.savingAccountAssembler.assembleSavings(savingsAccountData); + } + return savingsAccountDataList; + } + + private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { + + private final String schemaSql; + + SavingAccountMapperForInterestPosting() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); + sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); + sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); + sqlBuilder.append("sa.status_enum as statusEnum, "); + sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); + sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); + sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); + sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); + sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); + sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); + sqlBuilder.append("sa.closedon_date as closedOnDate,"); + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); + sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); + sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); + sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); + sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); + sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); + sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); + sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); + sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); + sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); + sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); + sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); + sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); + sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); + sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); + sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); + sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); + sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); + sqlBuilder.append("sa.account_balance_derived as accountBalance, "); + sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); + sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); + sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); + sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); + sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); + sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); + sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); + sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); + sqlBuilder.append("sa.withhold_tax as withHoldTax, "); + sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); + sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); + sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); + sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); + sqlBuilder.append("tg.id as taxGroupId, "); + sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); + sqlBuilder.append("from m_savings_account_transaction as sat "); + sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); + sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); + sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append("sp.id as productId, "); + sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); + sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); + sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); + sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); + sqlBuilder.append("sp.accounting_type as accountingType, "); + sqlBuilder.append("tr.id as transactionId, tr.transaction_type_enum as transactionType, "); + sqlBuilder.append("tr.transaction_date as transactionDate, tr.amount as transactionAmount,"); + sqlBuilder.append("tr.submitted_on_date as transSubmittedOnDate,tr.cumulative_balance_derived as cumulativeBalance,"); + sqlBuilder.append("tr.running_balance_derived as runningBalance, tr.is_reversed as reversed,"); + sqlBuilder.append("tr.balance_end_date_derived as balanceEndDate, tr.overdraft_amount_derived as overdraftAmount,"); + sqlBuilder.append("tr.is_manual as manualTransaction,tr.office_id as officeId, "); + sqlBuilder.append("pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, "); + sqlBuilder.append("pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, "); + sqlBuilder.append("pt.value as paymentTypeName, "); + sqlBuilder.append("msacpb.amount as paidByAmount, msacpb.id as chargesPaidById, "); + sqlBuilder.append( + "msac.id as chargeId, msac.amount as chargeAmount, msac.charge_time_enum as chargeTimeType, msac.is_penalty as isPenaltyCharge, "); + sqlBuilder.append("txd.id as taxDetailsId, txd.amount as taxAmount, "); + sqlBuilder.append("apm.gl_account_id as glAccountIdForInterestOnSavings, apm1.gl_account_id as glAccountIdForSavingsControl, "); + sqlBuilder.append( + "apm2.gl_account_id as glAccountIdForInterestReceivable,apm3.gl_account_id as glAccountIdForOverdraftPorfolio, "); + sqlBuilder.append("apm4.gl_account_id as glAccountIdForInterestPayable, "); + sqlBuilder.append( + "mtc.id as taxComponentId, mtc.debit_account_id as debitAccountId, mtc.credit_account_id as creditAccountId, mtc.percentage as taxPercentage "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + sqlBuilder.append("join m_savings_account_transaction tr on sa.id = tr.savings_account_id "); + sqlBuilder.append("left join m_payment_detail pd on pd.id = tr.payment_detail_id "); + sqlBuilder.append("left join m_payment_type pt on pt.id = pd.payment_type_id "); + sqlBuilder.append("left join m_savings_account_charge_paid_by msacpb on msacpb.savings_account_transaction_id = tr.id "); + sqlBuilder.append("left join m_savings_account_charge msac on msac.id = msacpb.savings_account_charge_id "); + sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); + sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); + sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); + sqlBuilder.append("left join m_savings_account_transaction_tax_details txd on txd.savings_transaction_id = tr.id "); + sqlBuilder.append("left join m_tax_component mtc on mtc.id = txd.tax_component_id "); + sqlBuilder.append( + "left join acc_product_mapping apm on apm.product_type = 2 and apm.product_id = sp.id and apm.financial_account_type=3 "); + sqlBuilder.append( + "left join acc_product_mapping apm1 on apm1.product_type = 2 and apm1.product_id = sp.id and apm1.financial_account_type=2 "); + sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); + sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); + sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); + // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a + // cumulative one: interest that is swept out to a linked account each month must never + // compound, and without this flag the engine compounded it. Left join because ordinary + // savings accounts have no term-and-preclosure row at all. + sqlBuilder.append( + "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public List extractData(final ResultSet rs) throws SQLException { + + List savingsAccountDataList = new ArrayList<>(); + HashMap savingsMap = new HashMap<>(); + String currencyCode = null; + Integer currencyDigits = null; + Integer inMultiplesOf = null; + CurrencyData currency = null; + HashMap transMap = new HashMap<>(); + HashMap taxDetails = new HashMap<>(); + HashMap chargeDetails = new HashMap<>(); + SavingsAccountTransactionData savingsAccountTransactionData = null; + SavingsAccountData savingsAccountData = null; + int count = 0; + + while (rs.next()) { + final Long id = rs.getLong("id"); + final Long transactionId = rs.getLong("transactionId"); + final Long taxDetailId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxDetailsId"); + final Long taxComponentId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxComponentId"); + final String accountNo = rs.getString("accountNo"); + final Long chargeId = rs.getLong("chargeId"); + + if (!savingsMap.containsValue(id)) { + if (count > 0) { + savingsAccountDataList.add(savingsAccountData); + } + count++; + savingsMap.put("id", id); + + final String externalId = rs.getString("externalId"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + final Long groupId = JdbcSupport.getLong(rs, "groupId"); + final Long groupOfficeId = JdbcSupport.getLong(rs, "groupOfficeId"); + final GroupGeneralData groupGeneralData = new GroupGeneralData(groupId, groupOfficeId); + + final Long clientId = JdbcSupport.getLong(rs, "clientId"); + final Long clientOfficeId = JdbcSupport.getLong(rs, "clientOfficeId"); + final ClientData clientData = ClientData.createClientForInterestPosting(clientId, clientOfficeId); + + final Long glAccountIdForInterestOnSavings = rs.getLong("glAccountIdForInterestOnSavings"); + final Long glAccountIdForSavingsControl = rs.getLong("glAccountIdForSavingsControl"); + + final Long glAccountIdForOverdraftPorfolio = rs.getLong("glAccountIdForOverdraftPorfolio"); + final Long glAccountIdForInterestReceivable = rs.getLong("glAccountIdForInterestReceivable"); + + final Long glAccountIdForInterestPayable = rs.getLong("glAccountIdForInterestPayable"); + + final Long productId = rs.getLong("productId"); + final Integer accountType = rs.getInt("accountingType"); + final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountType); + final EnumOptionData enumOptionDataForAccounting = new EnumOptionData(accountType.longValue(), + accountingRuleType.getCode(), accountingRuleType.getValue().toString()); + final SavingsProductData savingsProductData = SavingsProductData.createForInterestPosting(productId, + enumOptionDataForAccounting); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer daysToInactive = null; + Integer daysToDormancy = null; + Integer daysToEscheat = null; + + LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); + final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); + final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); + final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, null, + null, null, null, null, null, null, withdrawnOnDate, null, null, null, approvedOnDate, null, null, null, + activatedOnDate, null, null, null, closedOnDate, null, null, null); + + currencyCode = rs.getString("currencyCode"); + currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + currency = new CurrencyData(currencyCode, currencyDigits, inMultiplesOf); + + final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); + final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); + final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); + final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); + + final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); + final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); + final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); + final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); + final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); + final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, + "totalOverdraftInterestDerived"); + final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); + final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); + + final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minBalanceForInterestCalculation"); + final BigDecimal onHoldFunds = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldFunds"); + + final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldAmount"); + + BigDecimal availableBalance = accountBalance; + if (availableBalance != null && onHoldFunds != null) { + + availableBalance = availableBalance.subtract(onHoldFunds); + } + + if (availableBalance != null && onHoldAmount != null) { + + availableBalance = availableBalance.subtract(onHoldAmount); + } + + BigDecimal interestNotPosted = BigDecimal.ZERO; + LocalDate lastInterestCalculationDate = null; + if (totalInterestEarned != null) { + interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); + } + + final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, + totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, + totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, + lastInterestCalculationDate, availableBalance, interestPostedTillDate); + summary.setPrevInterestPostedTillDate(interestPostedTillDate); + + final boolean withHoldTax = rs.getBoolean("withHoldTax"); + final Long taxGroupId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxGroupId"); + TaxGroupData taxGroupData = null; + if (taxGroupId != null) { + taxGroupData = TaxGroupData.lookup(taxGroupId, null); + } + + final BigDecimal nominalAnnualInterestRate = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRate"); + + final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( + SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); + + final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( + SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); + + final EnumOptionData interestCalculationType = SavingsEnumerations.interestCalculationType( + SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); + + final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations + .interestCalculationDaysInYearType(SavingsInterestCalculationDaysInYearType + .fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); + + final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minRequiredOpeningBalance"); + + final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); + EnumOptionData lockinPeriodFrequencyType = null; + final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); + if (lockinPeriodFrequencyTypeValue != null) { + final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType + .fromInt(lockinPeriodFrequencyTypeValue); + lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); + } + + final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); + + final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); + final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); + final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRateOverdraft"); + final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minOverdraftForInterestCalculation"); + + final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); + final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); + final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); + final boolean lienAllowed = rs.getBoolean("lienAllowed"); + savingsAccountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, null, null, null, null, + productId, null, null, null, status, subStatus, null, timeline, currency, nominalAnnualInterestRate, + interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, + enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, + nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, + lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, + onHoldAmount); + + savingsAccountData.setClientData(clientData); + savingsAccountData.setGroupGeneralData(groupGeneralData); + savingsAccountData.setSavingsProduct(savingsProductData); + + savingsAccountData.setGlAccountIdForInterestReceivable(glAccountIdForInterestReceivable); + savingsAccountData.setGlAccountIdForOverdraftPorfolio(glAccountIdForOverdraftPorfolio); + + savingsAccountData.setGlAccountIdForInterestPayable(glAccountIdForInterestPayable); + + savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); + savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); + // Monthly-payout deposits must not compound: their interest leaves the account every + // period, so each period earns on the principal alone. + savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); + } + + if (!transMap.containsValue(transactionId)) { + + final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); + final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); + + final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); + final LocalDate balanceEndDate = JdbcSupport.getLocalDate(rs, "balanceEndDate"); + final LocalDate transSubmittedOnDate = JdbcSupport.getLocalDate(rs, "transSubmittedOnDate"); + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); + final BigDecimal overdraftAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "overdraftAmount"); + final BigDecimal outstandingChargeAmount = null; + final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); + final boolean reversed = rs.getBoolean("reversed"); + final Long officeId = rs.getLong("officeId"); + final BigDecimal cumulativeBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "cumulativeBalance"); + + final boolean postInterestAsOn = false; + + PaymentDetailData paymentDetailData = null; + if (transactionType.isDepositOrWithdrawal()) { + final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); + if (paymentTypeId != null) { + final String typeName = rs.getString("paymentTypeName"); + final PaymentTypeData paymentTypeData = new PaymentTypeData(paymentTypeId, typeName, null, false, null, null, + false); + paymentDetailData = new PaymentDetailData(id, paymentTypeData, null, null, null, null, null); + } + } + + savingsAccountTransactionData = SavingsAccountTransactionData.create(transactionId, transactionType, paymentDetailData, + id, accountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transSubmittedOnDate, + postInterestAsOn, cumulativeBalance, balanceEndDate); + savingsAccountTransactionData.setOverdraftAmount(overdraftAmount); + + transMap.put("id", transactionId); + if (savingsAccountData.getOfficeId() == null) { + savingsAccountData.setOfficeId(officeId); + } + + savingsAccountData.setSavingsAccountTransactionData(savingsAccountTransactionData); + } + + if (chargeId != null && !chargeDetails.containsValue(chargeId)) { + final boolean isPenalty = rs.getBoolean("isPenaltyCharge"); + final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeAmount"); + final Integer chargesTimeType = rs.getInt("chargeTimeType"); + final EnumOptionData enumOptionDataForChargesTimeType = new EnumOptionData(chargesTimeType.longValue(), null, null); + final SavingsAccountChargeData savingsAccountChargeData = new SavingsAccountChargeData(chargeId, chargeAmount, + enumOptionDataForChargesTimeType, isPenalty); + + final Long chargesPaidById = rs.getLong("chargesPaidById"); + final BigDecimal chargesPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "paidByAmount"); + final SavingsAccountChargesPaidByData savingsAccountChargesPaidByData = new SavingsAccountChargesPaidByData( + chargesPaidById, chargesPaid); + savingsAccountChargesPaidByData.setSavingsAccountChargeData(savingsAccountChargeData); + if (savingsAccountChargesPaidByData != null) { + savingsAccountTransactionData.setChargesPaidByData(savingsAccountChargesPaidByData); + } + + chargeDetails.put("id", chargeId); + } + + if (taxDetailId != null && !taxDetails.containsValue(taxDetailId)) { + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxAmount"); + final BigDecimal percentage = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxPercentage"); + final Long debitId = rs.getLong("debitAccountId"); + final Long creditId = rs.getLong("creditAccountId"); + final GLAccountData debitAccount = GLAccountData.createFrom(debitId); + final GLAccountData creditAccount = GLAccountData.createFrom(creditId); + + if (taxComponentId != null) { + final TaxComponentData taxComponent = TaxComponentData.createTaxComponent(taxComponentId, percentage, debitAccount, + creditAccount); + savingsAccountTransactionData.setTaxDetails(new TaxDetailsData(taxComponent, amount)); + } + + taxDetails.put("id", taxDetailId); + } + + } + if (savingsAccountData != null) { + savingsAccountDataList.add(savingsAccountData); + } + return savingsAccountDataList; + + } + } + + private static final class SavingAccountMapper implements RowMapper { + + private final String schemaSql; + + SavingAccountMapper() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("c.id as clientId, c.display_name as clientName, "); + sqlBuilder.append("g.id as groupId, g.display_name as groupName, "); + sqlBuilder.append("sp.id as productId, sp.name as productName, "); + sqlBuilder.append("s.id fieldOfficerId, s.display_name as fieldOfficerName, "); + sqlBuilder.append("sa.status_enum as statusEnum, "); + sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); + sqlBuilder.append("sa.reason_for_block as reasonForBlock, "); + sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); + sqlBuilder.append("sbu.username as submittedByUsername,"); + sqlBuilder.append("sbu.firstname as submittedByFirstname, sbu.lastname as submittedByLastname,"); + + sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); + sqlBuilder.append("rbu.username as rejectedByUsername,"); + sqlBuilder.append("rbu.firstname as rejectedByFirstname, rbu.lastname as rejectedByLastname,"); + + sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); + sqlBuilder.append("wbu.username as withdrawnByUsername,"); + sqlBuilder.append("wbu.firstname as withdrawnByFirstname, wbu.lastname as withdrawnByLastname,"); + + sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); + sqlBuilder.append("abu.username as approvedByUsername,"); + sqlBuilder.append("abu.firstname as approvedByFirstname, abu.lastname as approvedByLastname,"); + + sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); + sqlBuilder.append("avbu.username as activatedByUsername,"); + sqlBuilder.append("avbu.firstname as activatedByFirstname, avbu.lastname as activatedByLastname,"); + + sqlBuilder.append("sa.closedon_date as closedOnDate,"); + sqlBuilder.append("cbu.username as closedByUsername,"); + sqlBuilder.append("cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname,"); + + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); + sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); + + sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); + sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); + sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); + sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); + sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); + sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); + sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); + + sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); + sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); + sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); + sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); + + sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); + sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); + sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); + sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); + sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); + sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); + sqlBuilder.append("sa.account_balance_derived as accountBalance, "); + sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); + sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); + sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); + sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); + sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); + sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); + sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); + sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); + sqlBuilder.append("sa.withhold_tax as withHoldTax, "); + sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); + sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); + sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); + sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); + sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); + sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); + sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); + sqlBuilder.append("from m_savings_account_transaction as sat "); + sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); + sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); + sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); + sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); + sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); + sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); + // CBS extended fields + sqlBuilder.append("sa.cbs_account_type as cbsAccountType, "); + sqlBuilder.append("sa.customer_segment as customerSegment, "); + sqlBuilder.append("sa.account_status as accountStatus, "); + sqlBuilder.append("sa.mode_of_operation as modeOfOperation, "); + sqlBuilder.append("sa.special_category as specialCategory, "); + sqlBuilder.append("sa.weaker_section as weakerSection, "); + sqlBuilder.append("sa.guardian_name as guardianName, "); + sqlBuilder.append("sa.guardian_cif_number as guardianCifNumber, "); + sqlBuilder.append("sa.guardian_relationship as guardianRelationship, "); + sqlBuilder.append("sa.minor_date_of_birth as minorDateOfBirth, "); + sqlBuilder.append("sa.guardian_client_id as guardianClientId, "); + sqlBuilder.append("sa.introducer_client_id as introducerClientId, "); + sqlBuilder.append("sa.introducer_name as introducerName, "); + sqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, "); + sqlBuilder.append("sa.nominee_data as nomineeData, "); + sqlBuilder.append("sa.linked_account_id as linkedAccountId, "); + sqlBuilder.append("sa.linked_account_type as linkedAccountType, "); + sqlBuilder.append("sa.linked_account_no as linkedAccountNo, "); + sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, "); + sqlBuilder.append("sp.msme as msme, "); + sqlBuilder.append("COALESCE(sa.nomination_facility, sp.nomination_facility) as nominationFacility, "); + sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, "); + sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, "); + sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, "); + sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, "); + // Facility config fields + sqlBuilder.append("safc.atm_facility as atmFacility, "); + sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, "); + sqlBuilder.append("safc.upi_facility as upiFacility, "); + sqlBuilder.append("safc.pos_transactions_facility as posTransactionsFacility, "); + sqlBuilder.append("safc.cheque_book as chequeBook, "); + sqlBuilder.append("safc.imps_facility as impsFacility, "); + sqlBuilder.append("safc.sms_alerts as smsAlerts, "); + sqlBuilder.append("safc.bbps_facility as bbpsFacility, "); + sqlBuilder.append("safc.insurance_cover as insuranceCover, "); + sqlBuilder.append("safc.bepg_facility as bepgFacility, "); + sqlBuilder.append("safc.neft_facility as neftFacility, "); + sqlBuilder.append("safc.rtgs_facility as rtgsFacility, "); + sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, "); + sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, "); + sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, "); + sqlBuilder.append("sa.terminate_true as terminateTrue "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); + sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); + sqlBuilder.append("left join m_staff s ON s.id = sa.field_officer_id "); + sqlBuilder.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); + sqlBuilder.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); + sqlBuilder.append("left join m_appuser wbu on wbu.id = sa.withdrawnon_userid "); + sqlBuilder.append("left join m_appuser abu on abu.id = sa.approvedon_userid "); + sqlBuilder.append("left join m_appuser avbu on avbu.id = sa.activatedon_userid "); + sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid "); + sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); + sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id "); + sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long id = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + final String externalId = rs.getString("externalId"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + + final Long groupId = JdbcSupport.getLong(rs, "groupId"); + final String groupName = rs.getString("groupName"); + + final Long clientId = JdbcSupport.getLong(rs, "clientId"); + final String clientName = rs.getString("clientName"); + + final Long productId = rs.getLong("productId"); + final String productName = rs.getString("productName"); + + final Long fieldOfficerId = rs.getLong("fieldOfficerId"); + final String fieldOfficerName = rs.getString("fieldOfficerName"); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final String reasonForBlock = rs.getString("reasonForBlock"); + + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer daysToInactive = null; + Integer daysToDormancy = null; + Integer daysToEscheat = null; + + LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final String submittedByUsername = rs.getString("submittedByUsername"); + final String submittedByFirstname = rs.getString("submittedByFirstname"); + final String submittedByLastname = rs.getString("submittedByLastname"); + + final LocalDate rejectedOnDate = JdbcSupport.getLocalDate(rs, "rejectedOnDate"); + final String rejectedByUsername = rs.getString("rejectedByUsername"); + final String rejectedByFirstname = rs.getString("rejectedByFirstname"); + final String rejectedByLastname = rs.getString("rejectedByLastname"); + + final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); + final String withdrawnByUsername = rs.getString("withdrawnByUsername"); + final String withdrawnByFirstname = rs.getString("withdrawnByFirstname"); + final String withdrawnByLastname = rs.getString("withdrawnByLastname"); + + final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); + final String approvedByUsername = rs.getString("approvedByUsername"); + final String approvedByFirstname = rs.getString("approvedByFirstname"); + final String approvedByLastname = rs.getString("approvedByLastname"); + + final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); + final String activatedByUsername = rs.getString("activatedByUsername"); + final String activatedByFirstname = rs.getString("activatedByFirstname"); + final String activatedByLastname = rs.getString("activatedByLastname"); + + final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); + final String closedByUsername = rs.getString("closedByUsername"); + final String closedByFirstname = rs.getString("closedByFirstname"); + final String closedByLastname = rs.getString("closedByLastname"); + + final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, + submittedByUsername, submittedByFirstname, submittedByLastname, rejectedOnDate, rejectedByUsername, rejectedByFirstname, + rejectedByLastname, withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, + approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, + activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + final BigDecimal nominalAnnualInterestRate = rs.getBigDecimal("nominalAnnualInterestRate"); + + final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( + SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); + + final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( + SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); + + final EnumOptionData interestCalculationType = SavingsEnumerations + .interestCalculationType(SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); + + final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations.interestCalculationDaysInYearType( + SavingsInterestCalculationDaysInYearType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); + + final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredOpeningBalance"); + + final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); + EnumOptionData lockinPeriodFrequencyType = null; + final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); + if (lockinPeriodFrequencyTypeValue != null) { + final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType.fromInt(lockinPeriodFrequencyTypeValue); + lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); + } + + final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); + + final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); + final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); + final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRateOverdraft"); + final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minOverdraftForInterestCalculation"); + + final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); + final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); + + final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); + final boolean lienAllowed = rs.getBoolean("lienAllowed"); + + final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); + final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); + final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); + final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); + + final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); + final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); + final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); + final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); + final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); + final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, + "totalOverdraftInterestDerived"); + final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); + final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); + + final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minBalanceForInterestCalculation"); + final BigDecimal onHoldFunds = rs.getBigDecimal("onHoldFunds"); + + final BigDecimal onHoldAmount = rs.getBigDecimal("onHoldAmount"); + + BigDecimal availableBalance = accountBalance; + if (availableBalance != null && onHoldFunds != null) { + availableBalance = availableBalance.subtract(onHoldFunds); + } + + if (availableBalance != null && onHoldAmount != null) { + + availableBalance = availableBalance.subtract(onHoldAmount); + } + + BigDecimal interestNotPosted = BigDecimal.ZERO; + LocalDate lastInterestCalculationDate = null; + if (totalInterestEarned != null) { + interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); + } + + final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, + totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, + totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, lastInterestCalculationDate, + availableBalance, interestPostedTillDate); + + final boolean withHoldTax = rs.getBoolean("withHoldTax"); + final Long taxGroupId = JdbcSupport.getLong(rs, "taxGroupId"); + final String taxGroupName = rs.getString("taxGroupName"); + TaxGroupData taxGroupData = null; + if (taxGroupId != null) { + taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); + } + + final SavingsAccountData accountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, groupId, groupName, + clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, subStatus, reasonForBlock, + timeline, currency, nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, + interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, + lockinPeriodFrequencyType, withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, + enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, + nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, + lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, onHoldAmount); + + // CBS extended fields + accountData.setCbsAccountType(rs.getString("cbsAccountType")); + accountData.setCustomerSegment(rs.getString("customerSegment")); + accountData.setAccountStatus(rs.getString("accountStatus")); + accountData.setModeOfOperation(rs.getString("modeOfOperation")); + accountData.setSpecialCategory(rs.getString("specialCategory")); + accountData.setWeakerSection(rs.getString("weakerSection")); + accountData.setGuardianName(rs.getString("guardianName")); + accountData.setGuardianCifNumber(rs.getString("guardianCifNumber")); + accountData.setGuardianRelationship(rs.getString("guardianRelationship")); + accountData.setMinorDateOfBirth(JdbcSupport.getLocalDate(rs, "minorDateOfBirth")); + accountData.setGuardianClientId(JdbcSupport.getLong(rs, "guardianClientId")); + accountData.setIntroducerClientId(JdbcSupport.getLong(rs, "introducerClientId")); + accountData.setIntroducerName(rs.getString("introducerName")); + accountData.setIntroducerCifNumber(rs.getString("introducerCifNumber")); + accountData.setLinkedAccountId(JdbcSupport.getLong(rs, "linkedAccountId")); + accountData.setLinkedAccountType(rs.getString("linkedAccountType")); + accountData.setLinkedAccountNo(rs.getString("linkedAccountNo")); + accountData.setBalanceSwapOption(rs.getObject("balanceSwapOption") != null ? rs.getBoolean("balanceSwapOption") : null); + accountData.setMsme(rs.getObject("msme") != null ? rs.getBoolean("msme") : null); + accountData.setNominationFacility(rs.getObject("nominationFacility") != null ? rs.getBoolean("nominationFacility") : null); + accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null); + accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null); + accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null); + accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId")); + accountData.setShareAccountNo(rs.getString("shareAccountNo")); + accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null); + + // Nominee as parsed map + final String nomineeDataStr = rs.getString("nomineeData"); + if (nomineeDataStr != null && !nomineeDataStr.isEmpty()) { + try { + com.google.gson.Gson gson = new com.google.gson.Gson(); + @SuppressWarnings("unchecked") + java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, + new com.google.gson.reflect.TypeToken>() {}.getType()); + accountData.setNomineeData(nomineeMap); + } catch (Exception ignored) { + // intentionally ignored + } + } + + // Facility config + final boolean hasFacilityConfig = rs.getObject("atmFacility") != null; + if (hasFacilityConfig) { + final java.util.Map facilityConfigMap = new java.util.LinkedHashMap<>(); + facilityConfigMap.put("atmFacility", rs.getBoolean("atmFacility")); + facilityConfigMap.put("atmWithdrawalFacility", rs.getBoolean("atmWithdrawalFacility")); + facilityConfigMap.put("upiFacility", rs.getBoolean("upiFacility")); + facilityConfigMap.put("posTransactionsFacility", rs.getBoolean("posTransactionsFacility")); + facilityConfigMap.put("chequeBook", rs.getBoolean("chequeBook")); + facilityConfigMap.put("impsFacility", rs.getBoolean("impsFacility")); + facilityConfigMap.put("smsAlerts", rs.getBoolean("smsAlerts")); + facilityConfigMap.put("bbpsFacility", rs.getBoolean("bbpsFacility")); + facilityConfigMap.put("insuranceCover", rs.getBoolean("insuranceCover")); + facilityConfigMap.put("bepgFacility", rs.getBoolean("bepgFacility")); + facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility")); + facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility")); + facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility")); + facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc")); + facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard")); + accountData.setAccountFacilityConfig(facilityConfigMap); + } + + return accountData; + } + } + + private static final class SavingAccountMapperForLookup implements RowMapper { + + private final String schemaSql; + + SavingAccountMapperForLookup() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("sp.id as productId, sp.name as productName, "); + sqlBuilder.append("sa.status_enum as statusEnum "); + + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long id = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + + final Long productId = rs.getLong("productId"); + final String productName = rs.getString("productName"); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + + return SavingsAccountData.lookupWithProductDetails(id, accountNo, depositType, productId, productName, status); + } + } + + @Override + public SavingsAccountTransactionData retrieveDepositTransactionTemplate(final Long savingsId, + final DepositAccountType depositAccountType) { + + try { + final String sql = "select " + this.transactionTemplateMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ?"; + + return this.jdbcTemplate.queryForObject(sql, this.transactionTemplateMapper, // NOSONAR + new Object[] { savingsId, depositAccountType.getValue() }); + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(savingsId, e); + } + } + + @Override + public Collection retrieveAllTransactions(final Long savingsId, DepositAccountType depositAccountType) { + // #audit backward compatibility + final String sql = "select " + this.transactionsMapper.schema() + + " where sa.id = ? and sa.deposit_type_enum = ? order by tr.transaction_date DESC," + " tr." + CREATED_DATE_DB_FIELD + + " DESC, tr.created_date DESC, tr.id DESC"; + return this.jdbcTemplate.query(sql, this.transactionsMapper, new Object[] { savingsId, depositAccountType.getValue() }); // NOSONAR + } + + @Override + public SavingsAccountTransactionData retrieveSavingsTransaction(final Long savingsId, final Long transactionId, + DepositAccountType depositAccountType) { + + final String sql = "select " + this.transactionsMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ? and tr.id= ?"; + + return this.jdbcTemplate.queryForObject(sql, this.transactionsMapper, // NOSONAR + new Object[] { savingsId, depositAccountType.getValue(), transactionId }); + } + + private static final class SavingsAccountTransactionsForBatchMapper implements RowMapper { + + private final String schemaSql; + + SavingsAccountTransactionsForBatchMapper() { + + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("tr.id as transactionId, tr.ref_no as refNo "); + sqlBuilder.append("from m_savings_account_transaction tr"); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("transactionId"); + SavingsAccountTransactionData savingsAccountTransactionData = SavingsAccountTransactionData.create(id); + final String refNo = rs.getString("refNo"); + savingsAccountTransactionData.setRefNo(refNo); + return savingsAccountTransactionData; + } + } + + /* + * @Override public Collection retrieveAccountsWithAnnualFeeDue() { final String sql = + * "select " + this.annualFeeMapper.schema() + + * " where sa.annual_fee_next_due_date is not null and sa.annual_fee_next_due_date <= NOW()" ; + * + * return this.jdbcTemplate.query(sql, this.annualFeeMapper, new Object[] {}); } + */ + + public static class SavingsAccountTransactionsMapper implements RowMapper { + + private static final String SELECT = buildSelect(); + private static final String FROM = buildFrom(); + private static final String SCHEMA = SELECT + FROM; + + public SavingsAccountTransactionsMapper() {} + + protected static String buildSelect() { + return "tr.id as transactionId, tr.transaction_type_enum as transactionType, " + + "tr.transaction_date as transactionDate, tr.amount as transactionAmount, " + + "tr.release_id_of_hold_amount as releaseTransactionId, tr.reason_for_block as reasonForBlock, " + + "tr.submitted_on_date as submittedOnDate, au.username as submittedByUsername, nt.note as transactionNote, " + + "tr.running_balance_derived as runningBalance, tr.is_reversed as reversed, " + + "tr.is_reversal as isReversal, tr.original_transaction_id as originalTransactionId, tr.is_lien_transaction as lienTransaction, " + + "fromtran.id as fromTransferId, fromtran.is_reversed as fromTransferReversed, " + + "fromtran.transaction_date as fromTransferDate, fromtran.amount as fromTransferAmount, " + + "fromtran.description as fromTransferDescription, " + + "totran.id as toTransferId, totran.is_reversed as toTransferReversed, " + + "totran.transaction_date as toTransferDate, totran.amount as toTransferAmount, " + + "totran.description as toTransferDescription, sa.id as savingsId, sa.account_no as accountNo, " + + "pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, " + + "pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, " + + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, " + + "curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, " + + "curr.display_symbol as currencyDisplaySymbol, pt.value as paymentTypeName, " + "tr.is_manual as postInterestAsOn "; + } + + protected static String buildFrom() { + return " FROM m_savings_account_transaction tr join m_savings_account sa on tr.savings_account_id = sa.id " + + "join m_currency curr on curr.code = sa.currency_code " + + "left join m_account_transfer_transaction fromtran on fromtran.from_savings_transaction_id = tr.id " + + "left join m_account_transfer_transaction totran on totran.to_savings_transaction_id = tr.id " + + "left join m_payment_detail pd on tr.payment_detail_id = pd.id " + + "left join m_payment_type pt on pd.payment_type_id = pt.id left join m_appuser au on au.id= tr." + CREATED_BY_DB_FIELD + + " left join m_note nt ON nt.savings_account_transaction_id=tr.id "; + } + + public String schema() { + return SCHEMA; + } + + public String select() { + return SELECT; + } + + public String from() { + return FROM; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("transactionId"); + final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); + final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); + + final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); + final Long releaseTransactionId = rs.getLong("releaseTransactionId"); + final String reasonForBlock = rs.getString("reasonForBlock"); + final BigDecimal outstandingChargeAmount = null; + final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); + final boolean reversed = rs.getBoolean("reversed"); + final boolean isReversal = rs.getBoolean("isReversal"); + final Long originalTransactionId = rs.getLong("originalTransactionId"); + final Boolean lienTransaction = rs.getBoolean("lienTransaction"); + + final Long savingsId = rs.getLong("savingsId"); + final String accountNo = rs.getString("accountNo"); + final boolean postInterestAsOn = rs.getBoolean("postInterestAsOn"); + + PaymentDetailData paymentDetailData = null; + if (transactionType.isDepositOrWithdrawal()) { + final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); + if (paymentTypeId != null) { + final String typeName = rs.getString("paymentTypeName"); + final PaymentTypeData paymentType = PaymentTypeData.builder().id(paymentTypeId).name(typeName).build(); + final String accountNumber = rs.getString("accountNumber"); + final String checkNumber = rs.getString("checkNumber"); + final String routingCode = rs.getString("routingCode"); + final String receiptNumber = rs.getString("receiptNumber"); + final String bankNumber = rs.getString("bankNumber"); + paymentDetailData = new PaymentDetailData(id, paymentType, accountNumber, checkNumber, routingCode, receiptNumber, + bankNumber); + } + } + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + AccountTransferData transfer = null; + final Long fromTransferId = JdbcSupport.getLong(rs, "fromTransferId"); + final Long toTransferId = JdbcSupport.getLong(rs, "toTransferId"); + if (fromTransferId != null) { + final LocalDate fromTransferDate = JdbcSupport.getLocalDate(rs, "fromTransferDate"); + final BigDecimal fromTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "fromTransferAmount"); + final boolean fromTransferReversed = rs.getBoolean("fromTransferReversed"); + final String fromTransferDescription = rs.getString("fromTransferDescription"); + + transfer = AccountTransferData.transferBasicDetails(fromTransferId, currency, fromTransferAmount, fromTransferDate, + fromTransferDescription, fromTransferReversed); + } else if (toTransferId != null) { + final LocalDate toTransferDate = JdbcSupport.getLocalDate(rs, "toTransferDate"); + final BigDecimal toTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "toTransferAmount"); + final boolean toTransferReversed = rs.getBoolean("toTransferReversed"); + final String toTransferDescription = rs.getString("toTransferDescription"); + + transfer = AccountTransferData.transferBasicDetails(toTransferId, currency, toTransferAmount, toTransferDate, + toTransferDescription, toTransferReversed); + } + final String submittedByUsername = rs.getString("submittedByUsername"); + final String note = rs.getString("transactionNote"); + return SavingsAccountTransactionData.create(id, transactionType, paymentDetailData, savingsId, accountNo, date, currency, + amount, outstandingChargeAmount, runningBalance, reversed, transfer, submittedOnDate, postInterestAsOn, + submittedByUsername, note, isReversal, originalTransactionId, lienTransaction, releaseTransactionId, reasonForBlock); + } + } + + private static final class SavingsAccountTransactionTemplateMapper implements RowMapper { + + private final String schemaSql; + + SavingsAccountTransactionTemplateMapper() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); + sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long savingsId = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + return SavingsAccountTransactionData.template(savingsId, accountNo, DateUtils.getBusinessLocalDate(), currency); + } + } + + @Override + public Collection retrieveForLookup(Long clientId, Boolean overdraft) { + + SavingAccountMapperForLookup accountMapperForLookup = new SavingAccountMapperForLookup(); + final StringBuilder sqlBuilder = new StringBuilder("select " + accountMapperForLookup.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300"); + Object[] queryParameters = null; + if (overdraft == null) { + queryParameters = new Object[] { clientId }; + } else { + sqlBuilder.append(" and sa.allow_overdraft = ?"); + queryParameters = new Object[] { clientId, overdraft }; + } + return this.jdbcTemplate.query(sqlBuilder.toString(), accountMapperForLookup, queryParameters); + + } + + @Override + public List retrieveSavingsIdsPendingInactive(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 0 "); + String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " + + "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false" + + " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)"; + sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public List retrieveSavingsIdsPendingDormant(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 100 "); + sql.append(" and " + sqlGenerator.dateDiff("?", + "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + + " "); + sql.append(" >= sp.days_to_dormancy "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public List retrieveSavingsIdsPendingEscheat(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 200 "); + sql.append(" and " + sqlGenerator.dateDiff("?", + "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + + " "); + sql.append(" >= sp.days_to_escheat "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), Long.class, new Object[] { tenantLocalDate }); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public boolean isAccountBelongsToClient(final Long clientId, final Long accountId, final DepositAccountType depositAccountType, + final String currencyCode) { + try { + final StringBuilder buff = new StringBuilder("select count(*) from m_savings_account sa "); + buff.append( + " where sa.id = ? and sa.client_id = ? and sa.deposit_type_enum = ? and sa.currency_code = ? and sa.status_enum = 300"); + return this.jdbcTemplate.queryForObject(buff.toString(), Integer.class, accountId, clientId, depositAccountType.getValue(), + currencyCode) > 0; + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + /* + * private static final class SavingsAccountAnnualFeeMapper implements RowMapper { + * + * private final String schemaSql; + * + * public SavingsAccountAnnualFeeMapper() { final StringBuilder sqlBuilder = new StringBuilder(200); + * sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); sqlBuilder + * .append("sa.annual_fee_next_due_date as annualFeeNextDueDate "); sqlBuilder.append("from m_savings_account sa "); + * + * this.schemaSql = sqlBuilder.toString(); } + * + * public String schema() { return this.schemaSql; } + * + * @Override public SavingsAccountAnnualFeeData mapRow(final ResultSet rs, + * + * @SuppressWarnings("unused") final int rowNum) throws SQLException { + * + * final Long id = rs.getLong("id"); final String accountNo = rs.getString("accountNo"); final LocalDate + * annualFeeNextDueDate = JdbcSupport.getLocalDate(rs, "annualFeeNextDueDate"); + * + * return SavingsAccountAnnualFeeData.instance(id, accountNo, annualFeeNextDueDate); } } + */ + + @Override + public String retrieveAccountNumberByAccountId(Long accountId) { + try { + final String sql = "select s.account_no from m_savings_account s where s.id = ?"; + return this.jdbcTemplate.queryForObject(sql, String.class, accountId); + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + + @Override + public List getAccountsIdsByStatusPaged(Integer status, int pageSize, Long maxSavingsIdInList) { + String sql = new StringBuilder().append(" SELECT sa.id FROM m_savings_account sa ") + .append(" where sa.id > ? and sa.status_enum = ? ").append(" order by sa.id limit ?").toString(); + + try { + return this.jdbcTemplate.queryForList(sql, Long.class, new Object[] { maxSavingsIdInList, status, pageSize }); + } catch (EmptyResultDataAccessException e) { + return new ArrayList<>(); + } + } + + @Override + public Long retrieveAccountIdByExternalId(final ExternalId externalId) { + return savingsAccountRepositoryWrapper.findIdByExternalId(externalId); + } + + @Override + public List retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings) { + Long savingsId = (savings != null) ? savings.getId() : null; + Integer status = SavingsAccountStatusType.ACTIVE.getValue(); + Integer accountingRule = AccountingRuleType.ACCRUAL_PERIODIC.getValue(); + + return this.savingsAccountRepositoryWrapper.findAccrualData(tillDate, savingsId, status, accountingRule); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 212c9732c..8afd636c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -112,6 +112,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -166,6 +168,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi private final AppUserRepositoryWrapper appuserRepository; private final StandingInstructionRepository standingInstructionRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; + private final SavingsDailyInterestService savingsDailyInterestService; private final GSIMRepositoy gsimRepository; private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final ErrorHandler errorHandler; @@ -232,6 +236,17 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); businessEventNotifierService.notifyPostBusinessEvent(new SavingsActivateBusinessEvent(account)); + // Open the daily-interest ledger for the account here and now. The nightly run would find it + // tomorrow, but until then the account is absent from the ledger, which reads to a branch as + // though it is earning nothing. The product is enrolled first: an account whose product has + // no posting timetable would accrue with nothing to pay it out. + // + // Neither call can fail the activation — both run in their own transaction and swallow their + // own errors — because an account that is open must stay open even if its ledger row could + // not be written. + savingsDailyInterestOrchestrator.enrolProductOf(savingsId); + savingsDailyInterestService.accrueOnActivation(savingsId, DateUtils.getBusinessLocalDate()); + return new CommandProcessingResultBuilder() // .withEntityId(savingsId) // .withOfficeId(account.officeId()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java index 2663310e8..fc1ffe061 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java @@ -140,7 +140,6 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility()); facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility()); facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility()); - facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility()); facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc()); facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard()); product.setAccountFacilityConfig(facilityConfigMap); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java index e45876e82..54d4f96b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java @@ -52,6 +52,7 @@ import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.data.SavingsProductDataValidator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService; import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc; import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; @@ -103,6 +104,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository; private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository; + private final SavingsProductTermsCascadeService termsCascadeService; /* * Guaranteed to throw an exception no matter what the data integrity issue is. @@ -295,6 +297,19 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi this.savingProductRepository.saveAndFlush(product); } + // Interest terms are copied onto each account at creation and the engine reads that + // copy, so a rate or posting-period change here would otherwise leave every existing + // account on the terms it was opened with. Interest follows the product at this bank, + // so the edit is pushed down. Same transaction as the save: the product and its + // accounts move together or not at all. + // + // approveEdit() replays a maker-checker edit through this same method, so the checker + // path is covered by this one call. + final int accountsRealigned = this.termsCascadeService.cascadeInterestTerms(product.getId(), changes); + if (accountsRealigned > 0) { + changes.put("accountsRealignedToProductTerms", accountsRealigned); + } + return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // .with(changes).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 40f9f42c8..2cfc99881 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -84,6 +84,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository; import org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository; import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository; @@ -473,6 +475,7 @@ public class SavingsConfiguration { DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, + SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator, SavingsDailyInterestService savingsDailyInterestService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) { return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer, @@ -482,7 +485,8 @@ public class SavingsConfiguration { savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, - standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, + standingInstructionRepository, businessEventNotifierService, savingsDailyInterestOrchestrator, + savingsDailyInterestService, gsimRepository, savingsAccountInterestPostingService, errorHandler, denominationBreakdownWriter); } @@ -576,12 +580,13 @@ public class SavingsConfiguration { org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository, org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository, org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, - org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository) { + org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository, + org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService termsCascadeService) { return new SavingsProductWritePlatformServiceJpaRepositoryImpl(context, savingProductRepository, fromApiJsonDataValidator, savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository, facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper, commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository, - pendingEditSubmittedFieldRepository); + pendingEditSubmittedFieldRepository, termsCascadeService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index a2d1dcd0c..ac77c6bb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -50,7 +50,9 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; @@ -70,6 +72,7 @@ public class SponsorDDApiResource { private final SponsorDDReconciliationService reconciliationService; private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; + private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; private final PlatformSecurityContext context; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -233,6 +236,18 @@ public class SponsorDDApiResource { return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId())); } + @GET + @Path("leaflets/next-available") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve next available DD leaflet", description = "Returns the next sequential available leaflet for a branch and sponsor bank.") + public SponsorDDLeafletData retrieveNextAvailableLeaflet( + @QueryParam("branchId") final Long branchId, + @QueryParam("sponsorBankId") final Long sponsorBankId, + @QueryParam("inventoryId") final Long inventoryId) { + return this.readPlatformService.retrieveNextAvailableLeaflet(branchId, sponsorBankId, inventoryId); + } + // ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ── @POST @@ -260,7 +275,8 @@ public class SponsorDDApiResource { request.getCurrentAccountId(), request.getChequeNumber(), request.getChequeBankName(), - request.getCashierDepositRequestId() + request.getCashierDepositRequestId(), + request.getDenominationData() )); } @@ -410,6 +426,48 @@ public class SponsorDDApiResource { return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason())); } + // ── Amending an issued DD (beneficiary, payable-at branch and city) ──── + + @POST + @Path("leaflets/{leafletId}/amendment-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request an amendment to an issued DD", description = "Clerk (maker) step: requests manager approval to correct the beneficiary name, or the branch and city the draft is payable at. The draft is unchanged until approved.") + public CommandProcessingResult requestAmendment(@PathParam("leafletId") final Long leafletId, + final AmendmentRequestRequest request) { + return safely(() -> this.amendmentRequestWritePlatformService.requestAmendment(leafletId, request.getBeneficiaryName(), + request.getPayableAtBranch(), request.getPayableAtCity(), request.getReason())); + } + + @GET + @Path("amendment-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD amendment requests", description = "Returns DD amendment requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue. Each row carries both the old and the new details.") + public Collection retrieveAllAmendmentRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllAmendmentRequests(status); + } + + @POST + @Path("amendment-requests/{amendmentRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD amendment request", description = "Manager/head-cashier (checker) step: applies the corrected details to the draft.") + public CommandProcessingResult approveAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId) { + return safely(() -> this.amendmentRequestWritePlatformService.approveAmendmentRequest(amendmentRequestId)); + } + + @POST + @Path("amendment-requests/{amendmentRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD amendment request", description = "Manager/head-cashier (checker) step: refuses the correction, leaving the draft as issued.") + public CommandProcessingResult rejectAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId, + final RejectIssueRequestRequest request) { + return safely(() -> this.amendmentRequestWritePlatformService.rejectAmendmentRequest(amendmentRequestId, + request.getRejectionReason())); + } + // ── Sponsor DD GL account configuration ─────────────────────────────── @GET @@ -568,6 +626,7 @@ public class SponsorDDApiResource { // CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request // that received this cash, credited to the DD Cash Suspense GL account. private Long cashierDepositRequestId; + private String denominationData; } @lombok.Data @@ -602,6 +661,20 @@ public class SponsorDDApiResource { private String reason; } + /** + * The three details of an issued draft that can be corrected. + * + * A field left blank keeps what the draft already carries, so correcting only the city does not + * mean retyping the beneficiary and risking a second mistake. + */ + @lombok.Data + public static class AmendmentRequestRequest { + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + private String reason; + } + @lombok.Data public static class GlMappingRequest { private Long sponsorBankId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java new file mode 100644 index 000000000..38433434e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A pending or settled correction to an issued Demand Draft. + * + * Carries both the new details and the ones they replace, so the approval queue can show a checker + * what is actually changing rather than only what it would become — and so the record still reads + * correctly after the leaflet itself has been updated. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDAmendmentRequestData implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + + private String beneficiaryName; + private String payableAtBranch; + private String payableAtCity; + + private String oldBeneficiaryName; + private String oldPayableAtBranch; + private String oldPayableAtCity; + + private String reason; + private String status; + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java index 04a78d787..7cc8065d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java @@ -35,16 +35,22 @@ public class SponsorDDGlMappingData implements Serializable { private String sponsorBankName; private Long ddPayableGlAccountId; private String ddPayableGlAccountName; + private String ddPayableGlCode; private Long chequeClearingGlAccountId; private String chequeClearingGlAccountName; + private String chequeClearingGlCode; private Long cashGlAccountId; private String cashGlAccountName; + private String cashGlCode; private Long chargesIncomeGlAccountId; private String chargesIncomeGlAccountName; + private String chargesIncomeGlCode; private Long cashSuspenseGlAccountId; private String cashSuspenseGlAccountName; + private String cashSuspenseGlCode; private BigDecimal gstPercentage; private Long gstPayableGlAccountId; private String gstPayableGlAccountName; + private String gstPayableGlCode; private BigDecimal ddLimitAmount; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java index 8e1e75f71..ac63e0d96 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -57,4 +57,6 @@ public class SponsorDDLeafletData implements Serializable { private Long approvedUnconsumedReprintRequestId; private Long approverUserId; private String approverUsername; + /** Set while a correction to this draft is awaiting a manager. */ + private Long pendingAmendmentRequestId; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java new file mode 100644 index 000000000..8dc093a7c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +/** + * Corrections to an already-issued Demand Draft, under maker-checker. + * + * Only the beneficiary's name and the branch and city the draft is payable at can be amended. Those + * are details of who may present the instrument and where; getting one wrong makes the draft + * unpresentable, and the purchaser is already holding it by the time anyone notices. + * + * Face value, purchaser and funding are deliberately not amendable. The money for those has already + * moved, and editing them would leave the ledger disagreeing with the instrument — a draft wrong in + * that way is cancelled and re-issued instead. + */ +public interface SponsorDDAmendmentRequestWritePlatformService { + + /** Maker step: records what the clerk wants changed, leaving the leaflet untouched. */ + CommandProcessingResult requestAmendment(Long leafletId, String beneficiaryName, String payableAtBranch, String payableAtCity, + String reason); + + /** Checker step: applies the change to the leaflet. */ + CommandProcessingResult approveAmendmentRequest(Long amendmentRequestId); + + /** Checker step: refuses the change, leaving the draft as issued. */ + CommandProcessingResult rejectAmendmentRequest(Long amendmentRequestId, String rejectionReason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..513c1dfc5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java @@ -0,0 +1,217 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +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.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SponsorDDAmendmentRequestWritePlatformServiceImpl implements SponsorDDAmendmentRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Override + @Transactional + public CommandProcessingResult requestAmendment(final Long leafletId, final String beneficiaryName, + final String payableAtBranch, final String payableAtCity, final String reason) { + + final AppUser currentUser = this.context.authenticatedUser(); + final Map leaflet = findLeafletOrThrow(leafletId); + + // Only an issued draft is amendable. A cancelled or stopped one is out of circulation and a + // cleared one has already been paid, so correcting where it is payable would mean nothing. + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException( + "Only an issued Demand Draft can be amended. Current status: " + leaflet.get("status")); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required to amend an issued Demand Draft."); + } + + final String newBeneficiary = trimToNull(beneficiaryName); + final String newBranch = trimToNull(payableAtBranch); + final String newCity = trimToNull(payableAtCity); + if (newBeneficiary == null && newBranch == null && newCity == null) { + throw new IllegalArgumentException("Enter the beneficiary name, the payable-at branch or the city to amend."); + } + + final String oldBeneficiary = (String) leaflet.get("beneficiary_name"); + final String oldBranch = (String) leaflet.get("payable_at_branch"); + final String oldCity = (String) leaflet.get("payable_at_city"); + + // An amendment that changes nothing still costs a checker's attention, so it is refused + // rather than queued. + if (Objects.equals(newBeneficiary, oldBeneficiary) && Objects.equals(newBranch, oldBranch) + && Objects.equals(newCity, oldCity)) { + throw new IllegalArgumentException("The details submitted are the same as the ones on the draft."); + } + if (!this.jdbcTemplate + .queryForList("SELECT id FROM m_sponsor_dd_amendment_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId) + .isEmpty()) { + throw new IllegalStateException( + "An amendment is already pending for this Demand Draft. Wait for it to be approved or rejected."); + } + + // Blank fields keep what the draft already carries, so a clerk correcting only the city does + // not have to retype the beneficiary and risk a second mistake. + final String finalBeneficiary = newBeneficiary != null ? newBeneficiary : oldBeneficiary; + final String finalBranch = newBranch != null ? newBranch : oldBranch; + final String finalCity = newCity != null ? newCity : oldCity; + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement("INSERT INTO m_sponsor_dd_amendment_request " + + "(leaflet_id, beneficiary_name, payable_at_branch, payable_at_city, " + + " old_beneficiary_name, old_payable_at_branch, old_payable_at_city, " + + " reason, status, maker_id, created_date) VALUES (?,?,?,?,?,?,?,?,?,?,?)", new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setString(2, finalBeneficiary); + ps.setString(3, finalBranch); + ps.setString(4, finalCity); + ps.setString(5, oldBeneficiary); + ps.setString(6, oldBranch); + ps.setString(7, oldCity); + ps.setString(8, reason.trim()); + ps.setString(9, STATUS_PENDING); + ps.setLong(10, currentUser.getId()); + ps.setObject(11, now); + return ps; + }, kh); + + final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + return new CommandProcessingResultBuilder().withEntityId(newId).build(); + } + + @Override + @Transactional + public CommandProcessingResult approveAmendmentRequest(final Long amendmentRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findAmendmentRequestOrThrow(amendmentRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own amendment request."); + } + + final Long leafletId = ((Number) request.get("leaflet_id")).longValue(); + final Map leaflet = findLeafletOrThrow(leafletId); + // Re-checked at approval: the draft may have been cancelled or stopped while the request sat + // in the queue, and amending one that has left circulation would be a silent no-op. + if (!"ISSUED".equals(leaflet.get("status"))) { + throw new IllegalStateException("This Demand Draft is no longer issued. Current status: " + leaflet.get("status")); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET beneficiary_name = ?, payable_at_branch = ?, payable_at_city = ? WHERE id = ?", + request.get("beneficiary_name"), request.get("payable_at_branch"), request.get("payable_at_city"), leafletId); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, amendmentRequestId); + + return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).withSubEntityId(leafletId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectAmendmentRequest(final Long amendmentRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findAmendmentRequestOrThrow(amendmentRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own amendment request."); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update("UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ?, " + + "rejection_reason = ? WHERE id = ?", STATUS_REJECTED, currentUser.getId(), now, rejectionReason, + amendmentRequestId); + return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).build(); + } + + private static String trimToNull(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT id, status, beneficiary_name, payable_at_branch, payable_at_city FROM m_sponsor_dd_leaflet WHERE id = ?", + leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Map findAmendmentRequestOrThrow(final Long amendmentRequestId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_sponsor_dd_amendment_request WHERE id = ?", amendmentRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD amendment request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject amendment requests."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java index b5dcc10c4..498e8fc05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService { String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch, String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate, String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, - Long cashierDepositRequestId); + Long cashierDepositRequestId, String denominationData); /** * Manager/head-cashier (checker) step: performs the real debit/credit GL posting diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index e27e53176..d8febf463 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -101,7 +101,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, final String chequeNumber, final String chequeBankName, - final Long cashierDepositRequestId) { + final Long cashierDepositRequestId, final String denominationData) { final AppUser currentUser = this.context.authenticatedUser(); @@ -138,13 +138,45 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI String walkinPurchaserAadhaarMasked = null; Long cashReceiptJournalEntryId = null; + // Set when this service posts the counter receipt itself, so the receipt slip is raised once the + // request row exists to key it by. A receipt against a separate cashier deposit voucher is not + // included: that register raised its own slip when it took the notes. + boolean cashReceiptVoucherPending = false; if (METHOD_CASH.equals(paymentMethod)) { walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar); - final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount); - final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, - cashSuspenseGlAccountId); - cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + if (cashierDepositRequestId != null) { + final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, + cashSuspenseGlAccountId); + cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId); + } else { + // Taking cash straight over the counter, with no deposit request to have counted it. + // The breakdown is the only record of which notes were handed over, and the closing + // report is built entirely from breakdowns, so a draft accepted without one puts + // money in the drawer that no cash report can see. It was optional here, and every + // draft raised this way went in blank. + // An empty object counts as absent: a caller that sends "{}" has told us nothing + // about the notes, and the amount check below would pass it as a zero total. + if (denominationData == null || denominationData.isBlank() || denominationData.replaceAll("\\s", "").equals("{}")) { + throw new IllegalArgumentException( + "Enter the note and coin breakdown for the cash taken, or fund this draft from an approved cashier deposit request."); + } + validateDenominationTotal(denominationData, expectedTotal); + // The purchaser has handed over the notes to get this far, so the receipt is posted + // now rather than at approval. Cash in the drawer that the ledger does not know about + // is a cash difference at the end of the day, and if the request is later rejected + // there is nothing for rejectIssueRequest() to refund against. + // + // It lands in the cash-suspense account, not DD Payable: the draft has not been + // approved, so the bank holds the money without yet owing it on an instrument. + // Approval moves it on; rejection refunds it. + final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue(); + final String receiptComments = "Cash received for DD issuance request - " + beneficiaryName; + cashReceiptJournalEntryId = postGlOffsetEntry(cashGlAccountId, cashSuspenseGlAccountId, expectedTotal, null, null, + issuingBranchId, issuedDate, receiptComments, null); + cashReceiptVoucherPending = true; + } } else { validateClientExists(clientId); if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); @@ -168,8 +200,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, " + "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, " + "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, " - + "cashier_deposit_request_id, cash_receipt_journal_entry_id) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); ps.setLong(1, leafletId); if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); @@ -195,10 +227,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI ps.setObject(22, now); if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT); if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT); + ps.setString(25, denominationData); return ps; }, kh); final long newId = Objects.requireNonNull(kh.getKey()).longValue(); + if (METHOD_CASH.equals(paymentMethod) && cashierDepositRequestId == null && denominationData != null && !denominationData.isBlank()) { + savePendingDenomination(denominationData, newId, currentUser.getId(), currentUser.getUsername()); + } + if (cashReceiptVoucherPending) { + raiseCashReceiptVoucher(newId, beneficiaryName, faceValue.add(effectiveCharges).add(gstAmount), + ((Number) glMapping.get("cash_gl_account_id")).longValue(), + ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(), currentUser); + } return new CommandProcessingResultBuilder().withEntityId(newId).build(); } @@ -500,9 +541,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // and what is still unclaimed as two separate balances rather than only the net. drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber); } else { - // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the - // request was submitted, so approval clears the suspense rather than debiting cash again. - drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + // Cash payment. The money was taken at submission and is sitting in the cash-suspense + // account, whichever way it got there — a separate cashier deposit voucher, or the + // receipt this service posts when the purchaser pays at the counter. Approval moves it + // out of suspense onto the instrument. + // + // Debiting the cash account here instead would count the same notes twice: once when + // they were received, once again now. + if (request.get("cash_receipt_journal_entry_id") != null || request.get("cashier_deposit_request_id") != null) { + drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + } else { + // A request raised before the receipt was posted at submission: the cash never went + // through suspense, so it comes straight off the cash account. + drGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue(); + } } final String comments = "Sponsor DD issuance - DD No " + ddNumber; @@ -523,6 +575,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, resultingJournalEntryId, issueRequestId); + if (resultingJournalEntryId != null) { + this.jdbcTemplate.update( + "UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ? AND account_no = 'DD-ISSUE'", + resultingJournalEntryId, issueRequestId); + } + if (METHOD_DIVIDEND.equals(paymentMethod)) { settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName()); } @@ -609,8 +667,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue(); final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser"; + final BigDecimal refundTotal = faceValue.add(effectiveCharges).add(effectiveGst); cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId, - faceValue.add(effectiveCharges).add(effectiveGst), null, null, issuingBranchId, issuedDate, refundComments, null); + refundTotal, null, null, issuingBranchId, issuedDate, refundComments, null); + raiseCashRefundVoucher(issueRequestId, refundTotal, cashSuspenseGlAccountId, cashGlAccountId, currentUser); } final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); @@ -782,21 +842,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return requests; } - // ── GL mapping configuration ─────────────────────────────────────────── - @Override @Transactional - public CommandProcessingResult upsertGlMapping(final Long paymentTypeId, final Long ddPayableGlAccountId, + public CommandProcessingResult upsertGlMapping(final Long sponsorBankId, final Long ddPayableGlAccountId, final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId, final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, final BigDecimal ddLimitAmount) { - final AppUser currentUser = this.context.authenticatedUser(); - validateCheckerRole(currentUser); - if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) { - throw new IllegalArgumentException("GST percentage must be between 0 and 100."); - } - if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("DD Limit Amount cannot be negative."); + + if (ddPayableGlAccountId == null) { + throw new IllegalArgumentException("Sponsor DD Payable GL account is required."); } final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); @@ -839,6 +893,82 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // ── Validation helpers ───────────────────────────────────────────────── + private final com.google.gson.Gson gson = new com.google.gson.Gson(); + + private void validateDenominationTotal(final String denominationData, final BigDecimal expectedTotal) { + if (denominationData == null || denominationData.isBlank()) return; + final JsonObject denomMap; + try { + denomMap = this.gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + throw new IllegalArgumentException("Denomination data is not valid JSON."); + } + if (denomMap == null || denomMap.size() == 0) return; + + long total = 0; + for (final Map.Entry entry : denomMap.entrySet()) { + final int count; + try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } + if (count <= 0) continue; + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final int value; + try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } + total += (long) value * count; + } + // Cash is collected in whole rupees, so the breakdown is checked against the payable amount + // rounded up rather than against the paise. A GST-bearing draft comes to something like + // ₹111.80, and no combination of notes and coins reaches that -- demanding an exact match + // made the breakdown impossible to satisfy for the very drafts that most need one. Either + // figure is accepted, so a draft that happens to land on a whole rupee still balances. + final BigDecimal roundedUp = expectedTotal.setScale(0, java.math.RoundingMode.CEILING); + final BigDecimal counted = BigDecimal.valueOf(total); + if (counted.compareTo(expectedTotal) != 0 && counted.compareTo(roundedUp) != 0) { + throw new IllegalArgumentException("Total cash denomination (₹" + total + ") does not match total payable amount (₹" + + expectedTotal + ", collected as ₹" + roundedUp + ")."); + } + } + + private void savePendingDenomination(final String denominationData, final Long issueRequestId, + final Long cashierId, final String cashierUsername) { + if (denominationData == null || denominationData.isBlank()) return; + final JsonObject denomMap; + try { + denomMap = this.gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + return; + } + if (denomMap == null || denomMap.size() == 0) return; + + for (final Map.Entry entry : denomMap.entrySet()) { + final int count; + try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } + if (count <= 0) continue; + + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final String kind = parts[0]; + final int value; + try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } + + final List ids = this.jdbcTemplate.queryForList( + "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" + + " AND (deleted IS NULL OR deleted = false) LIMIT 1", + Long.class, value, kind); + if (ids.isEmpty()) continue; + final Long denomId = ids.get(0); + + this.jdbcTemplate.update( + "INSERT INTO m_savings_txn_denomination " + + "(txn_id, savings_account_id, account_no, client_id, denomination_id, " + + " denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) " + + "VALUES (?, NULL, 'DD-ISSUE', NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, 'CASH')", + issueRequestId, denomId, value, count, BigDecimal.valueOf((long) value * count), + cashierId, cashierUsername + ); + } + } + private Map findLeafletOrThrow(final Long leafletId) { final List> rows = this.jdbcTemplate.queryForList( "SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); @@ -868,25 +998,25 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1"); if (rows.isEmpty()) { throw new IllegalStateException( - "No Sponsor DD GL mapping is configured for this sponsor bank. Ask an administrator to configure the Sponsor DD Payable GL account first."); + "DD GL mapping is not configured. Ask an administrator to configure the Sponsor DD GL accounts in Admin > Sponsor DD GST / GL Settings first."); } final Map mapping = rows.get(0); + if (mapping.get("dd_payable_gl_account_id") == null) { + throw new IllegalStateException( + "No Sponsor DD Payable GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts."); + } if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) { throw new IllegalStateException( - "No cheque-clearing GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cheque payments."); + "No cheque-clearing GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cheque payments."); } if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) { throw new IllegalStateException( - "No cash-in-hand GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); - } - if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_suspense_gl_account_id") == null) { - throw new IllegalStateException( - "No cash-received-pending-approval GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments."); + "No cash-in-hand GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cash payments."); } final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; if (hasCharges && mapping.get("charges_income_gl_account_id") == null) { throw new IllegalStateException( - "No charges/commission income GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges."); + "No charges/commission income GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before collecting DD issuance charges."); } final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage"); if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0 @@ -1217,6 +1347,72 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } + /** + * Records the cash taken over the counter for a Demand Draft request in the voucher register. + * + * A receipt (CR), because the prefix names what happened to cash: the purchaser has handed notes + * over and the drawer is heavier. The approval slip raised later is a journal voucher — by then + * the money is only moving between GLs — and a rejection is a payment, because the notes go back. + * + * The credit is cash-suspense rather than DD Payable. The draft has not been approved, so the bank + * holds the money without yet owing it on an instrument. + * + * A failure here does not fail the submission: the notes are counted and the ledger already has + * the receipt, and refusing the request over a slip would leave the purchaser's money unrecorded. + */ + private void raiseCashReceiptVoucher(final Long issueRequestId, final String beneficiaryName, final BigDecimal total, + final Long cashGlAccountId, final Long cashSuspenseGlAccountId, final AppUser currentUser) { + try { + final List lines = new ArrayList<>(); + + final Map cash = this.voucherService.glAccount(cashGlAccountId); + lines.add(VoucherLine.debitGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total) + .withDescription("Cash received for DD to " + beneficiaryName)); + + final Map suspense = this.voucherService.glAccount(cashSuspenseGlAccountId); + lines.add(VoucherLine + .creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total) + .withDescription("Held pending DD approval")); + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId, + currentUser, VoucherService.PREFIX_CREDIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e); + } + } + + /** + * Records cash handed back to the purchaser when a Demand Draft request is rejected. + * + * A payment (DR): the notes leave the drawer. The receipt raised at submission stands — the money + * was genuinely taken and genuinely returned, and cancelling the receipt instead would leave the + * day's cash movements unaccounted for in both directions. + * + * A failure here does not fail the rejection, for the same reason it does not fail an issue: the + * refund is already posted, and holding a rejection over a slip would leave the purchaser's money + * in suspense with no way out. + */ + private void raiseCashRefundVoucher(final Long issueRequestId, final BigDecimal total, final Long cashSuspenseGlAccountId, + final Long cashGlAccountId, final AppUser currentUser) { + try { + final List lines = new ArrayList<>(); + + final Map suspense = this.voucherService.glAccount(cashSuspenseGlAccountId); + lines.add(VoucherLine + .debitGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total) + .withDescription("DD request #" + issueRequestId + " rejected")); + + final Map cash = this.voucherService.glAccount(cashGlAccountId); + lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total) + .withDescription("Cash refunded to purchaser")); + + this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId, + currentUser, VoucherService.PREFIX_DEBIT, lines); + } catch (final RuntimeException e) { + log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e); + } + } + /** * Records an approved Demand Draft issue in the voucher register. * @@ -1232,6 +1428,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * A failure here does not fail the issue. The draft is already raised, the leaflet already * consumed and the ledger already posted; withdrawing that over a slip would take back a * completed instrument for a paperwork reason. The problem is logged and the issue stands. + * + * Raised as a payment (DR), whichever way the draft was funded. For a DD funded from the + * purchaser's account or by cheque that is what it is — the money leaves at approval. For one + * paid in cash the notes were receipted at submit and approval only moves them out of suspense, + * so the register keeps two slips over the same money: a CR when it came in and this DR when it + * was applied. Read the two together, not the DR alone, when reconciling a day's cash. */ private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId, final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java index b013e284b..d427ab959 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -24,6 +24,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; public interface SponsorDDReadPlatformService { @@ -58,6 +59,11 @@ public interface SponsorDDReadPlatformService { Collection retrieveAllReprintRequests(String status); + /** DD amendment requests for the approval queue, optionally narrowed to one status. */ + Collection retrieveAllAmendmentRequests(String status); + + SponsorDDLeafletData retrieveNextAvailableLeaflet(Long branchId, Long sponsorBankId, Long inventoryId); + /** * Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash * Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 010b6bee4..471e767d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.EmptyResultDataAccessException; @@ -81,7 +82,8 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "leaf.issuing_branch_id, o.name as branch_name, " + "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, " + "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, " - + "apr.checker_id as approver_user_id, approver.username as approver_username " + + "apr.checker_id as approver_user_id, approver.username as approver_username, " + + "amr.id as pending_amendment_request_id " + "from m_sponsor_dd_leaflet leaf " + "left join m_client c on c.id = leaf.issued_to_client_id " + "left join m_office o on o.id = leaf.issuing_branch_id " @@ -89,7 +91,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' " + "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false " + "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' " - + "left join m_appuser approver on approver.id = apr.checker_id "; + + "left join m_appuser approver on approver.id = apr.checker_id " + // Lets the registry show that a correction is already awaiting a manager, instead of + // offering Edit again and letting a second request be raised over the same draft. + + "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "; @Override public Collection retrieveAllLeafletsByInventory(final Long inventoryId) { @@ -163,11 +168,13 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null; final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null; final String approverUsername = rs.getString("approver_username"); + final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null + ? rs.getLong("pending_amendment_request_id") : null; return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId, - approverUserId, approverUsername); + approverUserId, approverUsername, pendingAmendmentRequestId); } } @@ -231,10 +238,12 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe private static final String GL_MAPPING_SELECT = "select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, " - + "payable.name as dd_payable_gl_account_name, m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, " - + "m.cash_gl_account_id, cash.name as cash_gl_account_name, m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, " - + "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, " - + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, " + + "payable.name as dd_payable_gl_account_name, payable.gl_code as dd_payable_gl_code, " + + "m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, clearing.gl_code as cheque_clearing_gl_code, " + + "m.cash_gl_account_id, cash.name as cash_gl_account_name, cash.gl_code as cash_gl_code, " + + "m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, charges.gl_code as charges_income_gl_code, " + + "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, suspense.gl_code as cash_suspense_gl_code, " + + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, " + "m.dd_limit_amount " + "from m_sponsor_dd_gl_mapping m " + "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " @@ -268,22 +277,28 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final String sponsorBankName = rs.getString("sponsor_bank_name"); final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name"); + final String ddPayableGlCode = rs.getString("dd_payable_gl_code"); final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null; final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name"); + final String chequeClearingGlCode = rs.getString("cheque_clearing_gl_code"); final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null; final String cashGlAccountName = rs.getString("cash_gl_account_name"); + final String cashGlCode = rs.getString("cash_gl_code"); final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null; final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name"); + final String chargesIncomeGlCode = rs.getString("charges_income_gl_code"); final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null; final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name"); + final String cashSuspenseGlCode = rs.getString("cash_suspense_gl_code"); final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage"); final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null; final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name"); + final String gstPayableGlCode = rs.getString("gst_payable_gl_code"); final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount"); - return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, - chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName, - chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName, - gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount); + return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, ddPayableGlCode, + chequeClearingGlAccountId, chequeClearingGlAccountName, chequeClearingGlCode, cashGlAccountId, cashGlAccountName, cashGlCode, + chargesIncomeGlAccountId, chargesIncomeGlAccountName, chargesIncomeGlCode, cashSuspenseGlAccountId, cashSuspenseGlAccountName, cashSuspenseGlCode, + gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, gstPayableGlCode, ddLimitAmount); } } @@ -420,6 +435,57 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe } } + private static final String AMENDMENT_REQUEST_SELECT = + "select ar.id, ar.leaflet_id, leaf.dd_number, ar.beneficiary_name, ar.payable_at_branch, ar.payable_at_city, " + + "ar.old_beneficiary_name, ar.old_payable_at_branch, ar.old_payable_at_city, " + + "ar.reason, ar.status, ar.maker_id, maker.username as maker_username, ar.created_date, " + + "ar.checker_id, checker.username as checker_username, ar.checker_date, ar.rejection_reason " + + "from m_sponsor_dd_amendment_request ar " + + "join m_sponsor_dd_leaflet leaf on leaf.id = ar.leaflet_id " + + "join m_appuser maker on maker.id = ar.maker_id " + + "left join m_appuser checker on checker.id = ar.checker_id "; + + @Override + public Collection retrieveAllAmendmentRequests(final String status) { + if (status != null && !status.isBlank()) { + final String sql = AMENDMENT_REQUEST_SELECT + "where ar.status = ? order by ar.id desc"; + return this.jdbcTemplate.query(sql, new AmendmentRequestMapper(), status); + } + final String sql = AMENDMENT_REQUEST_SELECT + "order by ar.id desc"; + return this.jdbcTemplate.query(sql, new AmendmentRequestMapper()); + } + + private static final class AmendmentRequestMapper implements RowMapper { + + @Override + public SponsorDDAmendmentRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final Long leafletId = rs.getLong("leaflet_id"); + final String ddNumber = rs.getString("dd_number"); + final String beneficiaryName = rs.getString("beneficiary_name"); + final String payableAtBranch = rs.getString("payable_at_branch"); + final String payableAtCity = rs.getString("payable_at_city"); + final String oldBeneficiaryName = rs.getString("old_beneficiary_name"); + final String oldPayableAtBranch = rs.getString("old_payable_at_branch"); + final String oldPayableAtCity = rs.getString("old_payable_at_city"); + final String reason = rs.getString("reason"); + final String status = rs.getString("status"); + final Long makerId = rs.getLong("maker_id"); + final String makerUsername = rs.getString("maker_username"); + final LocalDateTime createdDate = rs.getTimestamp("created_date") != null + ? rs.getTimestamp("created_date").toLocalDateTime() : null; + final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null; + final String checkerUsername = rs.getString("checker_username"); + final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null + ? rs.getTimestamp("checker_date").toLocalDateTime() : null; + final String rejectionReason = rs.getString("rejection_reason"); + + return new SponsorDDAmendmentRequestData(id, leafletId, ddNumber, beneficiaryName, payableAtBranch, payableAtCity, + oldBeneficiaryName, oldPayableAtBranch, oldPayableAtCity, reason, status, makerId, makerUsername, createdDate, + checkerId, checkerUsername, checkerDate, rejectionReason); + } + } + @Override public Collection retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) { final List params = new ArrayList<>(); @@ -440,6 +506,35 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray()); } + @Override + public SponsorDDLeafletData retrieveNextAvailableLeaflet(final Long branchId, final Long sponsorBankId, final Long inventoryId) { + final StringBuilder sql = new StringBuilder(LEAFLET_SELECT); + sql.append("join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id "); + sql.append("where leaf.status = 'AVAILABLE' "); + sql.append("and leaf.id not in (select leaflet_id from m_sponsor_dd_issue_request where status = 'PENDING') "); + + final List params = new java.util.ArrayList<>(); + if (inventoryId != null) { + sql.append("and inv.id = ? "); + params.add(inventoryId); + } + if (branchId != null) { + sql.append("and inv.branch_id = ? "); + params.add(branchId); + } + if (sponsorBankId != null) { + sql.append("and inv.sponsor_bank_id = ? "); + params.add(sponsorBankId); + } + sql.append("order by inv.id asc, leaf.dd_number asc limit 1"); + + try { + return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray()); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + @Override public Collection retrieveAvailableCashierDepositRequests() { final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index ab901f3b3..75c44c2d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -434,7 +434,8 @@ public class VoucherVerificationApiResource { @GET @Path("report") public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, - @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) { + @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType, + @QueryParam("cashOnly") final Boolean cashOnly) { final AppUser currentUser = this.context.authenticatedUser(); final boolean seesEveryone = isCheckerRole(currentUser); @@ -460,7 +461,8 @@ public class VoucherVerificationApiResource { ver.status AS verification_status, dr.party_name AS debit_party, dr.account_no AS debit_account, cr.party_name AS credit_party, cr.account_no AS credit_account, - (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count + (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count, + cash.moved AS is_cash FROM m_voucher v LEFT JOIN m_office o ON o.id = v.branch_office_id LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no @@ -472,6 +474,52 @@ public class VoucherVerificationApiResource { SELECT party_name, account_no FROM m_voucher_line WHERE voucher_no = v.voucher_no AND entry_type = 'CR' ORDER BY line_no LIMIT 1) cr ON TRUE + -- A Demand Draft counts its notes at submission and keys them by the request id, + -- then re-keys them to the resulting journal entry once the draft is approved, + -- while the voucher keeps pointing at the request. Neither id alone finds the + -- notes for the whole life of the draft, so the request is joined to supply the + -- second one. + LEFT JOIN m_sponsor_dd_issue_request dd + ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST' AND dd.id = v.reference_id + -- Whether notes and coins were counted against this voucher. A denomination row + -- is written only when money was physically counted, so its presence proves cash; + -- its absence proves nothing, because several registers that genuinely pay out + -- cash record no breakdown at all (the clerk disbursement register and the loan + -- module write none, and a clerk withdrawal has none until it is disbursed). + -- + -- Each register keys these rows by an id of its own, all sharing one txn_id + -- column, so every branch is tied to the reference type that wrote it: an + -- unqualified match would let a deposit request borrow the notes counted for an + -- unrelated savings transaction of the same id. + LEFT JOIN LATERAL ( + SELECT TRUE AS moved FROM m_savings_txn_denomination d + WHERE d.txn_id = v.savings_txn_id + OR (v.reference_type IN ('SAVINGS_TRANSACTION', 'CASHIER_DEPOSIT_REQUEST', + 'CLERK_WITHDRAWAL_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST') + AND d.txn_id = v.reference_id) + OR d.txn_id = dd.resulting_journal_entry_id + LIMIT 1) cash ON TRUE + -- Whether either side of the voucher is the till itself. + -- + -- This is what separates money crossing the counter from a book entry between two + -- ledger accounts. A DD shows why it is needed: taking the purchaser's notes debits + -- Cash at Vault, so it is a real receipt, but approving the draft afterwards only + -- moves the amount from suspense to DD Payable -- no cash, no cashier, yet it + -- carries a DR number and was landing on the payment report as cash paid out. + -- + -- Two shapes have to be recognised. Registers that know their GL write a real + -- gl_account_id, matched against the cash-at-teller and cash-at-vault financial + -- activities (102 and 101). The counter registers do not carry a GL id for the + -- drawer at all and write the line named 'Cash in Hand' with a null id, so that + -- name is accepted as the marker it is used as. + LEFT JOIN LATERAL ( + SELECT TRUE AS touched FROM m_voucher_line cl + WHERE cl.voucher_no = v.voucher_no + AND (cl.gl_account_id IN ( + SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa + WHERE fa.financial_activity_type IN (101, 102)) + OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand')) + LIMIT 1) till ON TRUE WHERE v.created_at >= ? AND v.created_at < ? """); @@ -489,6 +537,32 @@ public class VoucherVerificationApiResource { sql.append(" AND v.voucher_type = ?"); args.add(voucherType.trim()); } + // A cashier's register is cash: what came over the counter in notes and coins. + // + // The test is whether a side of the voucher is the till (the `till` join above). That is + // what tells a receipt apart from a book entry between two ledger accounts, and it is the + // only test that gets a Demand Draft right: the same draft raises a genuine cash receipt + // when the notes are taken and a pure suspense-to-payable transfer when it is approved, + // both under the same reference type, and only the ledger accounts distinguish them. + // + // A voucher with no lines at all is kept. The registers attach their sides at approval, so + // a voucher raised and not yet actioned has none, and dropping those would hide real money + // from the report during the very window in which someone is looking for it. Under-reporting + // cash is the worse failure: a book entry on the register is visible and can be recognised, + // whereas cash that never appears surfaces only as an unexplained difference at the close. + // + // The type and reference exclusions below are kept for entries that carry a cash line yet + // still are not counter work -- interest posted into an account, a cheque settling between + // banks, a fee debited to a member, an RTGS leaving the bank's own cash account. + final boolean restrictToCash = Boolean.TRUE.equals(cashOnly); + if (restrictToCash) { + sql.append(" AND (till.touched IS TRUE OR NOT EXISTS (" + + "SELECT 1 FROM m_voucher_line nl WHERE nl.voucher_no = v.voucher_no))"); + sql.append(" AND v.voucher_type <> 'INTEREST_POSTING'"); + sql.append(" AND v.voucher_no NOT LIKE 'JV/%'"); + sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN (" + + "'CLEARING_POSTING', 'CHEQUEBOOK_CHARGE', 'BANK_TRANSFER_LOG'))"); + } sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC"); final List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); @@ -518,6 +592,10 @@ public class VoucherVerificationApiResource { // so the row says how much it is hiding rather than misrepresenting the voucher. final int lineCount = ((Number) r.get("line_count")).intValue(); m.put("extraLines", Math.max(0, lineCount - 2)); + // True when notes and coins were counted against this voucher. False means unconfirmed, + // not non-cash: the registers that record no breakdown still pay real money out. The + // screen marks these rather than dropping them. + m.put("denominationCounted", Boolean.TRUE.equals(r.get("is_cash"))); out.add(m); } @@ -528,6 +606,7 @@ public class VoucherVerificationApiResource { // Tells the screen whether to offer the user filter at all, rather than having it guess // from the caller's roles a second time. res.put("canSeeAllUsers", seesEveryone); + res.put("cashOnly", restrictToCash); res.put("vouchers", out); res.put("count", out.size()); res.put("totalAmount", total); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 472524953..09f6075d8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -1,6 +1,5 @@ - + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml index 33482f5f2..6c415241d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml @@ -108,6 +108,11 @@ next_posting_date is deliberately left null: the first run works it out from the posting period and the financial year start, and fills it in. Seeding a guess here would put a wrong date in front of somebody before the job has ever run. + + NOTE: this seeds only the products that existed when it ran. A product created afterwards is + enrolled by SavingsDailyInterestOrchestrator.enrolNewProducts() on the next run, and 0525 + catches up any that were left behind in between. Do not edit this changeset to widen it — + it has already run, and changing it breaks the checksum. --> diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml new file mode 100644 index 000000000..527c0e0e1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml new file mode 100644 index 000000000..7f39ff6ae --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + deposit_type_enum = 100 AND (is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false) + AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL + + + + + + + + + + + + + + (is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false) + AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml new file mode 100644 index 000000000..c28c2a810 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml new file mode 100644 index 000000000..fffcb9421 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + is_dbt_beneficiary = true AND sub_status_enum IN (100, 200) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml new file mode 100644 index 000000000..f79bf8a10 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + deposit_type_enum = 100 + + + + + + + + + + + + + + + + + + + + sub_status_enum IN (100, 200) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml new file mode 100644 index 000000000..d73074fd4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_savings_interest_posting_schedule + (savings_product_id, posting_period_enum, run_at_time, is_active) + SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true + FROM m_savings_product sp + WHERE sp.deposit_type_enum = 100 + AND sp.interest_posting_period_enum IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s + WHERE s.savings_product_id = sp.id) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml new file mode 100644 index 000000000..b09709538 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml @@ -0,0 +1,97 @@ + + + + + + + + SELECT + d.savings_account_id AS savings_account_id, + sa.account_no AS account_no, + sa.client_id AS client_id, + c.display_name AS client_name, + o.name AS office_name, + d.savings_product_id AS savings_product_id, + sp.name AS product_name, + sa.currency_code AS currency_code, + + d.balance_date AS as_on_date, + d.opening_balance AS opening_balance, + d.closing_balance AS closing_balance, + d.daily_interest AS interest_today, + + d.cycle_number AS cycle_number, + d.cycle_start_date AS cycle_start_date, + d.cycle_end_date AS cycle_end_date, + d.cycle_cumulative_interest AS interest_this_cycle, + (d.cycle_end_date - d.balance_date) AS days_to_posting, + d.is_posting_day AS is_posting_day, + d.posting_date AS posting_date, + + d.interest_rate AS interest_rate, + d.days_in_year AS days_in_year, + d.compounding_period_enum AS compounding_period_enum, + d.posting_period_enum AS posting_period_enum, + d.calculation_type_enum AS calculation_type_enum, + d.min_balance_for_interest AS min_balance_for_interest, + d.below_min_balance AS below_min_balance, + + COALESCE(t.posted_total, 0) AS interest_posted_total, + t.last_posted_on AS last_posted_on, + COALESCE(t.days_accrued, 0) AS days_accrued, + d.last_recomputed_on AS last_recomputed_on + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + JOIN m_savings_product sp ON sp.id = d.savings_product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN ( + SELECT savings_account_id, + SUM(COALESCE(posted_amount, 0)) AS posted_total, + MAX(posting_date) FILTER (WHERE savings_transaction_id IS NOT NULL) AS last_posted_on, + COUNT(*) AS days_accrued + FROM m_savings_daily_interest + GROUP BY savings_account_id + ) t ON t.savings_account_id = d.savings_account_id + WHERE d.balance_date = ( + SELECT MAX(balance_date) FROM m_savings_daily_interest + WHERE savings_account_id = d.savings_account_id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml new file mode 100644 index 000000000..a093b16d1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + DELETE FROM m_savings_daily_interest d + WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x + WHERE x.savings_account_id = d.savings_account_id) + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml new file mode 100644 index 000000000..1dfeba576 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml @@ -0,0 +1,51 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200904' + + + + UPDATE acc_product_mapping + SET gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = '200904') + WHERE financial_account_type = 14 + AND gl_account_id <> (SELECT id FROM acc_gl_account WHERE gl_code = '200904') + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml new file mode 100644 index 000000000..4d082bd4e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml new file mode 100644 index 000000000..9ecd0b75d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml new file mode 100644 index 000000000..d0b6010b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml @@ -0,0 +1,47 @@ + + + + + + + + + name = 'Post Interest For Savings' + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml new file mode 100644 index 000000000..0f3287b98 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java index 809c69456..772c96b2a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java @@ -56,8 +56,9 @@ public class SavingsProductAccountingDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()) .value(savingsReferenceAccountId).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element) - || ignoreExistenceValidation) { + // Transfers In Suspense is not offered by the savings product screen, so it is validated only + // when actually supplied — ignoreExistenceValidation must not make it mandatory on create. + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) { final Long transfersInSuspenseAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) @@ -85,22 +86,21 @@ public class SavingsProductAccountingDataValidator { .value(incomeFromPenaltyId).notNull().integerGreaterThanZero(); } if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) { - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element) - || ignoreExistenceValidation) { + // Overdraft Control / Income From Interest / Write Off are likewise not offered by the + // savings product screen, so they too are validated only when actually supplied. + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)) { final Long overdraftControlAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) .value(overdraftControlAccountId).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element) - || ignoreExistenceValidation) { + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) { final Long incomeFromInterest = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()) .value(incomeFromInterest).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element) - || ignoreExistenceValidation) { + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) { final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java new file mode 100644 index 000000000..1375764e6 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java @@ -0,0 +1,158 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Record that an escheated balance was actually remitted to RBI's Depositor Education and Awareness + * Fund. + * + *

+ * Escheat and remittance are two separate events. The nightly job closes the account and takes the + * balance out; sending that money to RBI happens outside this system, on RBI's own schedule. Until + * a row exists here the balance is out of the customer's account but not accounted for as having + * reached the fund, and the escheat report says exactly that rather than assuming it was sent. + * + *

+ * A customer can claim an unclaimed balance at any time even after remittance: the bank pays them + * and recovers the amount from the fund. {@code refundedOn} records that, so a remittance that has + * since been drawn back is not counted twice. + * + *

+ * One row per savings account — an account's balance goes to DEAF once. + */ +@Entity +@Table(name = "m_deaf_remittance") +public class DeafRemittance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "savings_account_id", nullable = false, unique = true) + private Long savingsAccountId; + + @Column(name = "client_id", nullable = false) + private Long clientId; + + /** The amount taken out of the account at escheat, which is what is owed to the fund. */ + @Column(name = "amount", nullable = false, scale = 6, precision = 19) + private BigDecimal amount; + + /** The day the account was closed and the balance removed. */ + @Column(name = "escheated_on", nullable = false) + private LocalDate escheatedOn; + + /** The day the money was sent to RBI. */ + @Column(name = "remitted_on", nullable = false) + private LocalDate remittedOn; + + /** RBI's acknowledgement for the remittance, as printed on their advice. */ + @Column(name = "deaf_reference", length = 100) + private String deafReference; + + /** Set only if the customer later claimed the balance and it was recovered from the fund. */ + @Column(name = "refunded_on") + private LocalDate refundedOn; + + @Column(name = "remarks", length = 500) + private String remarks; + + @Column(name = "recorded_by_user_id") + private Long recordedByUserId; + + @Column(name = "recorded_on", nullable = false) + private LocalDateTime recordedOn; + + protected DeafRemittance() { + // JPA + } + + private DeafRemittance(final Long savingsAccountId, final Long clientId, final BigDecimal amount, + final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks, + final Long recordedByUserId) { + this.savingsAccountId = savingsAccountId; + this.clientId = clientId; + this.amount = amount; + this.escheatedOn = escheatedOn; + this.remittedOn = remittedOn; + this.deafReference = deafReference; + this.remarks = remarks; + this.recordedByUserId = recordedByUserId; + this.recordedOn = LocalDateTime.now(); + } + + public static DeafRemittance record(final Long savingsAccountId, final Long clientId, final BigDecimal amount, + final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks, + final Long recordedByUserId) { + return new DeafRemittance(savingsAccountId, clientId, amount, escheatedOn, remittedOn, deafReference, remarks, + recordedByUserId); + } + + /** Records that the customer claimed the balance back and it was recovered from the fund. */ + public void markRefunded(final LocalDate refundedOn) { + this.refundedOn = refundedOn; + } + + public Long getId() { + return id; + } + + public Long getSavingsAccountId() { + return savingsAccountId; + } + + public Long getClientId() { + return clientId; + } + + public BigDecimal getAmount() { + return amount; + } + + public LocalDate getEscheatedOn() { + return escheatedOn; + } + + public LocalDate getRemittedOn() { + return remittedOn; + } + + public String getDeafReference() { + return deafReference; + } + + public LocalDate getRefundedOn() { + return refundedOn; + } + + public String getRemarks() { + return remarks; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java new file mode 100644 index 000000000..3a61ecca4 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeafRemittanceRepository extends JpaRepository { + + Optional findBySavingsAccountId(Long savingsAccountId); + + boolean existsBySavingsAccountId(Long savingsAccountId); + + List findBySavingsAccountIdIn(List savingsAccountIds); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java index 7f9069144..6faafff41 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java @@ -68,9 +68,6 @@ public class FacilityConfigEditFields { @Column(name = "fc_inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "fc_withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "fc_deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index e7cfd0d76..9fa9fe453 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -417,7 +417,6 @@ public final class ProductPendingEditFieldMapper { f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility")); f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility")); f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility")); - f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility")); f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc")); f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard")); return true; @@ -441,7 +440,6 @@ public final class ProductPendingEditFieldMapper { config.addProperty("neftFacility", f.getNeftFacility()); config.addProperty("rtgsFacility", f.getRtgsFacility()); config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility()); - config.addProperty("withdrawalFacility", f.getWithdrawalFacility()); config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc()); config.addProperty("virtualDebitCard", f.getVirtualDebitCard()); root.add("accountFacilityConfig", config); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 039ca6d90..1a0057291 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -1,4615 +1,4687 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.savings.domain; - -import static java.time.temporal.ChronoUnit.DAYS; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lienAllowedParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.localeParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyTypeParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.maxAllowedLienLimitParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minOverdraftForInterestCalculationParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequiredBalanceParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withdrawalFeeForTransfersParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; - -import com.google.gson.JsonArray; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorColumn; -import jakarta.persistence.DiscriminatorType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Embedded; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OrderBy; -import jakarta.persistence.Table; -import jakarta.persistence.Transient; -import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import java.math.BigDecimal; -import java.math.MathContext; -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.ApiParameterError; -import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; -import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; -import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator; -import org.apache.fineract.interoperation.domain.InteropIdentifier; -import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.organisation.monetary.domain.MoneyHelper; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.staff.domain.Staff; -import org.apache.fineract.portfolio.accountdetails.domain.AccountType; -import org.apache.fineract.portfolio.charge.domain.Charge; -import org.apache.fineract.portfolio.charge.exception.SavingsAccountChargeNotFoundException; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientKyc; -import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; -import org.apache.fineract.portfolio.group.domain.Group; -import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; -import org.apache.fineract.portfolio.savings.DepositAccountType; -import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; -import org.apache.fineract.portfolio.savings.SavingsApiConstants; -import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; -import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; -import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; -import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; -import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; -import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountCreditsBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountDebitsBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; -import org.apache.fineract.portfolio.savings.exception.SavingsActivityPriorToClientTransferException; -import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentDateException; -import org.apache.fineract.portfolio.savings.exception.SavingsOfficerUnassignmentDateException; -import org.apache.fineract.portfolio.savings.exception.SavingsTransferTransactionsCannotBeUndoneException; -import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; -import org.apache.fineract.portfolio.tax.domain.TaxComponent; -import org.apache.fineract.portfolio.tax.domain.TaxGroup; -import org.apache.fineract.portfolio.tax.service.TaxUtils; -import org.apache.fineract.useradministration.domain.AppUser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.util.CollectionUtils; - -@Entity -@Table(name = "m_savings_account", uniqueConstraints = { @UniqueConstraint(columnNames = { "account_no" }, name = "sa_account_no_UNIQUE"), - @UniqueConstraint(columnNames = { "external_id" }, name = "sa_external_id_UNIQUE") }) -@Inheritance(strategy = InheritanceType.SINGLE_TABLE) -@DiscriminatorColumn(name = "deposit_type_enum", discriminatorType = DiscriminatorType.INTEGER) -@DiscriminatorValue("100") -@SuppressWarnings({ "MemberName" }) -public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom implements IDepositAccountType { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsAccount.class); - - @Version - int version; - - @Column(name = "account_no", length = 20, unique = true, nullable = false) - protected String accountNumber; - - @Column(name = "external_id", nullable = true) - protected ExternalId externalId; - - @ManyToOne(optional = true) - @JoinColumn(name = "client_id", nullable = true) - protected Client client; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "group_id", nullable = true) - protected Group group; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "gsim_id", nullable = true) - private GroupSavingsIndividualMonitoring gsim; - - @ManyToOne - @JoinColumn(name = "product_id", nullable = false) - protected SavingsProduct product; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "field_officer_id", nullable = true) - protected Staff savingsOfficer; - - @Column(name = "status_enum", nullable = false) - protected Integer status; - - @Column(name = "sub_status_enum", nullable = false) - protected Integer sub_status = 0; - - @Column(name = "account_type_enum", nullable = false) - protected Integer accountType; - - @Column(name = "submittedon_date", nullable = true) - protected LocalDate submittedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "submittedon_userid", nullable = true) - protected AppUser submittedBy; - - @Column(name = "rejectedon_date") - protected LocalDate rejectedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "rejectedon_userid", nullable = true) - protected AppUser rejectedBy; - - @Column(name = "withdrawnon_date") - protected LocalDate withdrawnOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "withdrawnon_userid", nullable = true) - protected AppUser withdrawnBy; - - @Column(name = "approvedon_date") - protected LocalDate approvedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "approvedon_userid", nullable = true) - protected AppUser approvedBy; - - @Column(name = "activatedon_date", nullable = true) - protected LocalDate activatedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "activatedon_userid", nullable = true) - protected AppUser activatedBy; - - @Column(name = "closedon_date") - protected LocalDate closedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "closedon_userid", nullable = true) - protected AppUser closedBy; - - @Column(name = "reason_for_block", nullable = true) - protected String reasonForBlock; - - @Embedded - protected MonetaryCurrency currency; - - @Column(name = "nominal_annual_interest_rate", scale = 6, precision = 19, nullable = false) - protected BigDecimal nominalAnnualInterestRate; - - /** - * The interest period is the span of time at the end of which savings in a client's account earn interest. - * - * A value from the {@link SavingsCompoundingInterestPeriodType} enumeration. - */ - @Column(name = "interest_compounding_period_enum", nullable = false) - protected Integer interestCompoundingPeriodType; - - /** - * A value from the {@link SavingsPostingInterestPeriodType} enumeration. - */ - @Column(name = "interest_posting_period_enum", nullable = false) - protected Integer interestPostingPeriodType; - - /** - * A value from the {@link SavingsInterestCalculationType} enumeration. - */ - @Column(name = "interest_calculation_type_enum", nullable = false) - protected Integer interestCalculationType; - - /** - * A value from the {@link SavingsInterestCalculationDaysInYearType} enumeration. - */ - @Column(name = "interest_calculation_days_in_year_type_enum", nullable = false) - protected Integer interestCalculationDaysInYearType; - - @Column(name = "min_required_opening_balance", scale = 6, precision = 19, nullable = true) - protected BigDecimal minRequiredOpeningBalance; - - @Column(name = "lockin_period_frequency", nullable = true) - protected Integer lockinPeriodFrequency; - - @Column(name = "lockin_period_frequency_enum", nullable = true) - protected Integer lockinPeriodFrequencyType; - - /** - * When account becomes active this field is derived if lockinPeriodFrequency and - * lockinPeriodFrequencyType details are present. - */ - @Column(name = "lockedin_until_date_derived", nullable = true) - protected LocalDate lockedInUntilDate; - - @Column(name = "withdrawal_fee_for_transfer", nullable = true) - protected boolean withdrawalFeeApplicableForTransfer; - - @Column(name = "allow_overdraft") - private boolean allowOverdraft; - - @Column(name = "overdraft_limit", scale = 6, precision = 19, nullable = true) - private BigDecimal overdraftLimit; - - @Column(name = "nominal_annual_interest_rate_overdraft", scale = 6, precision = 19, nullable = true) - protected BigDecimal nominalAnnualInterestRateOverdraft; - - @Column(name = "min_overdraft_for_interest_calculation", scale = 6, precision = 19, nullable = true) - private BigDecimal minOverdraftForInterestCalculation; - - @Column(name = "enforce_min_required_balance") - private boolean enforceMinRequiredBalance; - - @Column(name = "min_required_balance", scale = 6, precision = 19, nullable = true) - private BigDecimal minRequiredBalance; - - @Column(name = "is_lien_allowed", nullable = false) - private boolean lienAllowed; - - @Column(name = "max_allowed_lien_limit", scale = 6, precision = 19, nullable = true) - private BigDecimal maxAllowedLienLimit; - - @Column(name = "on_hold_funds_derived", scale = 6, precision = 19, nullable = true) - private BigDecimal onHoldFunds; - - @Column(name = "start_interest_calculation_date") - protected LocalDate startInterestCalculationDate; - - @Embedded - protected SavingsAccountSummary summary; - - @OrderBy(value = "dateOf, createdDate, id") - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - protected List transactions = new ArrayList<>(); - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - protected Set charges = new HashSet<>(); - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private Set savingsOfficerHistory = new HashSet<>(); - - @Transient - protected boolean accountNumberRequiresAutoGeneration = false; - @Transient - protected SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; - @Transient - protected SavingsHelper savingsHelper; - @Transient - protected List savingsAccountTransactions = new ArrayList<>(); - - @Column(name = "deposit_type_enum", insertable = false, updatable = false) - private Integer depositType; - - @Column(name = "min_balance_for_interest_calculation", scale = 6, precision = 19, nullable = true) - private BigDecimal minBalanceForInterestCalculation; - - @Column(name = "withhold_tax", nullable = false) - protected boolean withHoldTax; - - @ManyToOne - @JoinColumn(name = "tax_group_id") - private TaxGroup taxGroup; - - @Column(name = "accrued_till_date") - private LocalDate accruedTillDate; - - @Column(name = "last_closed_business_date") - private LocalDate lastClosedBusinessDate; - - @Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmount; - - /** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet - * realized, or a presented cheque not yet settled) -- kept as its own running total, separate from - * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures - * rather than one bucket doing double duty. */ - @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO; - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) - protected List identifiers = new ArrayList<>(); - - public transient ConfigurationDomainService configurationDomainService; - - protected SavingsAccount() { - // - } - - public static SavingsAccount createNewApplicationForSubmittal(final Client client, final Group group, final SavingsProduct product, - final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, - final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, - final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, - final boolean withHoldTax) { - - final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; - return new SavingsAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, - submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, enforceMinRequiredBalance, - minRequiredBalance, maxAllowedLienLimit, lienAllowed, nominalAnnualInterestRateOverdraft, - minOverdraftForInterestCalculation, withHoldTax); - } - - protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, - final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, boolean withHoldTax) { - this(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, - nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, false, null, null, false, null, - null, withHoldTax); - } - - protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff savingsOfficer, - final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, - final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, - final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, boolean withHoldTax) { - this.client = client; - this.group = group; - this.product = product; - this.savingsOfficer = savingsOfficer; - if (StringUtils.isBlank(accountNo)) { - this.accountNumber = new RandomPasswordGenerator(19).generate(); - this.accountNumberRequiresAutoGeneration = true; - } else { - this.accountNumber = accountNo; - } - - this.currency = product.currency(); - this.externalId = externalId; - this.status = status.getValue(); - this.accountType = accountType.getValue(); - this.submittedOnDate = submittedOnDate; - this.submittedBy = submittedBy; - this.nominalAnnualInterestRate = nominalAnnualInterestRate; - this.interestCompoundingPeriodType = interestCompoundingPeriodType.getValue(); - this.interestPostingPeriodType = interestPostingPeriodType.getValue(); - this.interestCalculationType = interestCalculationType.getValue(); - this.interestCalculationDaysInYearType = interestCalculationDaysInYearType.getValue(); - this.minRequiredOpeningBalance = minRequiredOpeningBalance; - this.lockinPeriodFrequency = lockinPeriodFrequency; - if (lockinPeriodFrequencyType != null) { - this.lockinPeriodFrequencyType = lockinPeriodFrequencyType.getValue(); - } - this.withdrawalFeeApplicableForTransfer = withdrawalFeeApplicableForTransfer; - - if (!CollectionUtils.isEmpty(savingsAccountCharges)) { - this.charges = associateChargesWithThisSavingsAccount(savingsAccountCharges); - } - - this.summary = new SavingsAccountSummary(); - this.allowOverdraft = allowOverdraft; - this.overdraftLimit = overdraftLimit; - this.nominalAnnualInterestRateOverdraft = nominalAnnualInterestRateOverdraft; - this.minOverdraftForInterestCalculation = minOverdraftForInterestCalculation; - esnureOverdraftLimitsSetForOverdraftAccounts(); - - this.enforceMinRequiredBalance = enforceMinRequiredBalance; - this.minRequiredBalance = minRequiredBalance; - this.lienAllowed = lienAllowed; - this.maxAllowedLienLimit = maxAllowedLienLimit; - this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); - // this.savingsOfficerHistory = null; - this.withHoldTax = withHoldTax; - this.taxGroup = product.getTaxGroup(); - } - - /** - * Used after fetching/hydrating a {@link SavingsAccount} object to inject helper services/components used for - * update summary details after events/transactions on a {@link SavingsAccount}. - */ - public void setHelpers(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, - final SavingsHelper savingsHelper) { - this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; - this.savingsHelper = savingsHelper; - } - - public void setSavingsAccountTransactions(final List savingsAccountTransactions) { - this.savingsAccountTransactions.addAll(savingsAccountTransactions); - } - - public List getSavingsAccountTransactionsWithPivotConfig() { - return this.savingsAccountTransactions; - } - - public ExternalId getExternalId() { - return externalId; - } - - public boolean isNotActive() { - return !isActive(); - } - - public boolean isActive() { - return SavingsAccountStatusType.fromInt(this.status).isActive(); - } - - public boolean isNotSubmittedAndPendingApproval() { - return !isSubmittedAndPendingApproval(); - } - - public boolean isSubmittedAndPendingApproval() { - return SavingsAccountStatusType.fromInt(this.status).isSubmittedAndPendingApproval(); - } - - public boolean isApproved() { - return SavingsAccountStatusType.fromInt(this.status).isApproved(); - } - - public boolean isActivated() { - boolean isActive = false; - if (this.activatedOnDate != null) { - isActive = true; - } - return isActive; - } - - public boolean isClosed() { - return SavingsAccountStatusType.fromInt(this.status).isClosed(); - } - - public List getIdentifiers() { - return identifiers; - } - - /** - * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto - * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the - * correction, so plain savings and recurring deposit behaviour is unchanged. - */ - protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { - return true; - } - - /** - * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an - * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation - * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit - * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called - * instead. - */ - public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { - return true; - } - - /** - * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest - * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running - * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, - * regardless of whether any posting correction was needed -- without re-running the interest - * posting/correction loop that recalculation drives. - */ - public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, - final boolean postReversals) { - Money openingAccountBalance = Money.zero(this.currency); - if (backdatedTxnsAllowedTill) { - if (this.summary.getLastInterestCalculationDate() == null) { - openingAccountBalance = Money.zero(this.currency); - } else { - openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - } - recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); - if (!backdatedTxnsAllowedTill) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } else { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } - } - - public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, - postReversals); - if (postingPeriods.isEmpty()) { - return; - } - - Money interestPostedToDate = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - interestPostedToDate = Money.of(this.currency, this.summary.getTotalInterestPosted()); - } - - boolean recalucateDailyBalanceDetails = false; - boolean applyWithHoldTax = isWithHoldTaxApplicableForInterestPosting(); - final List withholdTransactions = new ArrayList<>(); - - if (backdatedTxnsAllowedTill) { - withholdTransactions.addAll(findWithHoldSavingsTransactionsWithPivotConfig()); - } else { - withholdTransactions.addAll(findWithHoldTransactions()); - } - - // Reverse manual interest postings that no longer line up with any period this run computed, - // BEFORE the periods below are applied. - // - // A user posting ("post interest as on D") lands its transaction on D-1, because - // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in - // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 - // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the - // posting's date matches no period end the second time around: findInterestPostingTransactionFor - // below misses it, it is never reversed or corrected, and it survives alongside a new posting - // covering the very same days. The member is credited twice, and total_interest_posted_derived - // drifts above what was actually earned. - // - // A posting is only treated as orphaned, and only reversed, when BOTH hold: - // - its date is not any period's posting date, so the loop below will never find and correct it; - // - some period that WILL be posted in this run (posting date on or before - // interestPostingUpToDate) covers that date, so the days it credited are being credited again. - // The second condition is what keeps this from ever destroying interest. A run whose remaining - // period only matures in the future posts nothing for those days -- the recalculation that - // follows a withdrawal is exactly that case -- and reversing there would silently take earned - // interest back off the member instead of de-duplicating it. - // Automatic postings are left alone: they sit on natural period ends, which always recur. - final Set periodPostingDates = new HashSet<>(); - final List repostedIntervals = new ArrayList<>(); - for (final PostingPeriod period : postingPeriods) { - periodPostingDates.add(period.dateOfPostingTransaction()); - if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { - repostedIntervals.add(period.getPeriodInterval()); - } - } - final List postingsInScope = backdatedTxnsAllowedTill - ? getSavingsAccountTransactionsWithPivotConfig() - : getTransactions(); - for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { - final LocalDate transactionDate = transaction.getTransactionDate(); - if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() - || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { - continue; - } - boolean coveredByAPostedPeriod = false; - for (final LocalDateInterval interval : repostedIntervals) { - if (interval.contains(transactionDate)) { - coveredByAPostedPeriod = true; - break; - } - } - if (!coveredByAPostedPeriod) { - continue; - } - transaction.reverse(); - final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); - if (strandedWithholdTransaction != null) { - strandedWithholdTransaction.reverse(); - } - recalucateDailyBalanceDetails = true; - } - - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); - final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); - - if (!DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate)) { - interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); - - SavingsAccountTransaction postingTransaction = null; - if (backdatedTxnsAllowedTill) { - postingTransaction = findInterestPostingSavingsTransactionWithPivotConfig(interestPostingTransactionDate); - } else { - postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate); - } - if (postingTransaction == null) { - SavingsAccountTransaction newPostingTransaction; - if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { - - newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), interestPostingTransactionDate, - interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); - } else { - newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), interestPostingTransactionDate, - interestEarnedToBePostedForPeriod.negated(), interestPostingPeriod.isUserPosting()); - } - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(newPostingTransaction); - } else { - addTransaction(newPostingTransaction); - } - if (applyWithHoldTax) { - createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, - backdatedTxnsAllowedTill); - } - recalucateDailyBalanceDetails = true; - } else { - boolean correctionRequired = false; - if (postingTransaction.isInterestPostingAndNotReversed()) { - correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); - } else { - correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); - } - if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { - boolean applyWithHoldTaxForOldTransaction = false; - postingTransaction.reverse(); - SavingsAccountTransaction reversal = null; - if (postReversals) { - reversal = SavingsAccountTransaction.reversal(postingTransaction); - } - final SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingTransactionDate, - withholdTransactions); - if (withholdTransaction != null) { - withholdTransaction.reverse(); - applyWithHoldTaxForOldTransaction = true; - } - SavingsAccountTransaction newPostingTransaction; - if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { - newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod, - interestPostingPeriod.isUserPosting()); - } else { - newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), - interestPostingPeriod.isUserPosting()); - } - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(newPostingTransaction); - if (reversal != null) { - addTransactionToExisting(reversal); - } - } else { - addTransaction(newPostingTransaction); - if (reversal != null) { - addTransaction(reversal); - } - } - if (applyWithHoldTaxForOldTransaction) { - createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, - backdatedTxnsAllowedTill); - } - recalucateDailyBalanceDetails = true; - } - } - } - } - - if (recalucateDailyBalanceDetails) { - // no openingBalance concept supported yet but probably will to - // allow - // for migrations. - Money openingAccountBalance = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - if (this.summary.getLastInterestCalculationDate() == null) { - openingAccountBalance = Money.zero(this.currency); - } else { - openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - } - - // update existing transactions so derived balance fields are - // correct. - recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); - } - - if (!backdatedTxnsAllowedTill) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } else { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } - } - - protected List findWithHoldTransactions() { - final List withholdTransactions = new ArrayList<>(); - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isWithHoldTaxAndNotReversed()) { - withholdTransactions.add(transaction); - } - } - return withholdTransactions; - } - - protected List findWithHoldSavingsTransactionsWithPivotConfig() { - final List withholdTransactions = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isWithHoldTaxAndNotReversed() && !transaction.isReversalTransaction()) { - withholdTransactions.add(transaction); - } - } - return withholdTransactions; - } - - private boolean isWithHoldTaxApplicableForInterestPosting() { - return false; - } - - protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { - SavingsAccountTransaction postingTransation = null; - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { - postingTransation = transaction; - break; - } - } - return postingTransation; - } - - protected SavingsAccountTransaction findInterestPostingSavingsTransactionWithPivotConfig(final LocalDate postingDate) { - SavingsAccountTransaction postingTransation = null; - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { - postingTransation = transaction; - break; - } - } - return postingTransation; - } - - protected SavingsAccountTransaction findTransactionFor(final LocalDate postingDate, - final List transactions) { - SavingsAccountTransaction transaction = null; - for (final SavingsAccountTransaction savingsAccountTransaction : transactions) { - if (savingsAccountTransaction.occursOn(postingDate)) { - transaction = savingsAccountTransaction; - break; - } - } - return transaction; - } - - private Map calculateTaxSplit(final BigDecimal amount, final LocalDate date, final int scale) { - if (this.taxGroup == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - return new HashMap<>(); - } - ClientKyc kyc = this.client != null ? this.client.getClientKyc() : null; - - // 1. Form 15G/15H Waiver Check - boolean waiveTds = false; - if (kyc != null && Boolean.TRUE.equals(kyc.getForm15Applicable())) { - String fy = kyc.getForm15Fy(); - if (isDateInFinancialYear(date, fy)) { - waiveTds = true; - } - } - - if (waiveTds) { - return new HashMap<>(); - } - - // 2. Determine if 20% higher rate applies (missing/unlinked PAN) - boolean applyHigherTdsRate = false; - if (kyc != null) { - boolean hasVerifiedPan = kyc.isPanVerified() || Boolean.TRUE.equals(kyc.getPanDeclarationTaken()); - String seedingStatus = kyc.getPanAadhaarSeedingStatus(); - - if (!hasVerifiedPan || "N".equalsIgnoreCase(seedingStatus)) { - applyHigherTdsRate = true; - } - } else { - // Default to higher rate (20%) if no KYC record is found - applyHigherTdsRate = true; - } - - // 3. Split tax - if (applyHigherTdsRate) { - return TaxUtils.splitTaxWithOverride(amount, date, this.taxGroup.getTaxGroupMappings(), scale, new BigDecimal("20.00")); - } else { - return TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), scale); - } - } - - private boolean isDateInFinancialYear(final LocalDate date, final String fy) { - if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { - return false; - } - try { - int startYear = Integer.parseInt(fy.substring(0, 4)); - LocalDate start = LocalDate.of(startYear, 4, 1); - LocalDate end = LocalDate.of(startYear + 1, 3, 31); - return !date.isBefore(start) && !date.isAfter(end); - } catch (Exception e) { - return false; - } - } - - protected boolean createWithHoldTransaction(final BigDecimal amount, final LocalDate date, final boolean backdatedTxnsAllowedTill) { - boolean isTaxAdded = false; - if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = calculateTaxSplit(amount, date, amount.scale()); - BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); - if (totalTax.compareTo(BigDecimal.ZERO) > 0) { - SavingsAccountTransaction withholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), date, - Money.of(currency, totalTax), taxSplit); - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(withholdTransaction); - } else { - addTransaction(withholdTransaction); - } - isTaxAdded = true; - } - } - return isTaxAdded; - } - - protected boolean updateWithHoldTransaction(final BigDecimal amount, final SavingsAccountTransaction withholdTransaction) { - boolean isTaxAdded = false; - if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = calculateTaxSplit(amount, withholdTransaction.getTransactionDate(), amount.scale()); - BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); - if (totalTax.compareTo(BigDecimal.ZERO) > 0) { - if (withholdTransaction.getId() == null) { - withholdTransaction.setAmount(Money.of(currency, totalTax)); - withholdTransaction.getTaxDetails().clear(); - SavingsAccountTransaction.updateTaxDetails(taxSplit, withholdTransaction); - isTaxAdded = true; - } else if (totalTax.compareTo(withholdTransaction.getAmount()) != 0) { - withholdTransaction.reverse(); - SavingsAccountTransaction newWithholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), - withholdTransaction.getTransactionDate(), Money.of(currency, totalTax), taxSplit); - addTransaction(newWithholdTransaction); - isTaxAdded = true; - } - } - } - return isTaxAdded; - } - - // Determine the last transaction for given day - protected SavingsAccountTransaction findLastTransaction(final LocalDate date) { - - SavingsAccountTransaction savingsTransaction = null; - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { - savingsTransaction = transaction; - break; - } - } - - return savingsTransaction; - } - - /** - * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. - *

- * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening - * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the - * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match - * would leave the opening balance at zero on any pivot date that happens to carry no transaction. - *

- * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final - * match is the latest one. - */ - protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { - SavingsAccountTransaction candidate = null; - for (final SavingsAccountTransaction transaction : getTransactions()) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() - && DateUtils.isBefore(transaction.getTransactionDate(), date)) { - candidate = transaction; - } - } - return candidate; - } - - protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { - SavingsAccountTransaction savingsTransaction = null; - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { - savingsTransaction = transaction; - break; - } - } - return savingsTransaction; - } - - public List getManualPostingDates() { - List transactions = new ArrayList<>(); - for (SavingsAccountTransaction trans : this.transactions) { - if (trans.isInterestPosting() && trans.isNotReversed() && !trans.isReversalTransaction() && trans.isManualTransaction()) { - transactions.add(trans.getTransactionDate()); - } - } - return transactions; - } - - public List getManualPostingDatesWithPivotConfig() { - List transactions = new ArrayList<>(); - for (SavingsAccountTransaction trans : this.savingsAccountTransactions) { - if (trans.isInterestPosting() && trans.isNotReversed() && trans.isManualTransaction()) { - transactions.add(trans.getTransactionDate()); - } - } - return transactions; - } - - /** - * All interest calculation based on END-OF-DAY-BALANCE. - * - * Interest calculation is performed on-the-fly over all account transactions. - * - * - * 1. Calculate Interest From Beginning Of Account 1a. determine the 'crediting' periods that exist for this savings - * acccount 1b. determine the 'compounding' periods that exist within each 'crediting' period calculate the amount - * of interest due at the end of each 'crediting' period check if an existing 'interest posting' transaction exists - * for date and matches the amount posted - * - * @param isInterestTransfer - * TODO - */ - - public List calculateInterestUsing(final MathContext mc, final LocalDate upToInterestCalculationDate, - boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - - // no openingBalance concept supported yet but probably will to allow for migrations. - // Check global configurations and 'pivot' date is null - Money openingAccountBalance = backdatedTxnsAllowedTill ? Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()) - : Money.zero(this.currency); - - // update existing transactions so derived balance fields are correct. - recalculateDailyBalances(openingAccountBalance, upToInterestCalculationDate, backdatedTxnsAllowedTill, postReversals); - - final List allPostingPeriods = new ArrayList<>(); - if (hasInterestCalculation() || hasOverdraftInterestCalculation()) { - // 1. default to calculate interest based on entire history OR - // 2. determine latest 'posting period' and find interest credited to that period - - // A generate list of EndOfDayBalances (not including interest postings) - final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType - .fromInt(this.interestPostingPeriodType); - - final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(this.interestCompoundingPeriodType); - - final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType - .fromInt(this.interestCalculationDaysInYearType); - List postedAsOnDates = null; - if (backdatedTxnsAllowedTill) { - postedAsOnDates = getManualPostingDatesWithPivotConfig(); - } else { - postedAsOnDates = getManualPostingDates(); - } - if (postInterestOnDate != null) { - postedAsOnDates.add(postInterestOnDate); - } - final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( - getStartInterestCalculationDate(), upToInterestCalculationDate, postingPeriodType, financialYearBeginningMonth, - postedAsOnDates); - - Money periodStartingBalance; - if (this.startInterestCalculationDate != null && !this.getStartInterestCalculationDate().equals(this.getActivationDate())) { - LocalDate startInterestCalculationDate = this.startInterestCalculationDate; - SavingsAccountTransaction transaction = null; - if (backdatedTxnsAllowedTill) { - transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); - } else { - // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the - // first posting period and is applied there, so seeding from it would apply it twice -- - // and an exact-date match would leave this at zero whenever the pivot date carries no - // transaction of its own, silently accruing the whole term on a zero balance. - transaction = findLastTransactionBefore(startInterestCalculationDate); - } - - if (transaction == null) { - periodStartingBalance = Money.zero(this.currency); - } else if (backdatedTxnsAllowedTill) { - periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } else { - // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the - // assembler on the backdated-transactions path, so reading it here returned zero and - // silently wiped the opening balance. The balance carried by the last transaction - // before the start date is the same figure, and is always present. - periodStartingBalance = transaction.getRunningBalance(this.currency); - } - } else { - periodStartingBalance = Money.zero(this.currency); - } - - final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType - .fromInt(this.interestCalculationType); - final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, upToInterestCalculationDate); - final BigDecimal overdraftInterestRateAsFraction = getEffectiveOverdraftInterestRateAsFraction(mc); - final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); - final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); - final Money minOverdraftForInterestCalculation = Money.of(getCurrency(), this.minOverdraftForInterestCalculation); - - for (final LocalDateInterval periodInterval : postingPeriodIntervals) { - - boolean isUserPosting = false; - if (postedAsOnDates.contains(periodInterval.endDate().plusDays(1))) { - isUserPosting = true; - } - - PostingPeriod postingPeriod = null; - List orderedNonInterestPostingTransactions = null; - if (backdatedTxnsAllowedTill) { - orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig(); - } else { - orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingTransactions(); - } - - List savingsAccountTransactionDetailsForPostingPeriod = toSavingsAccountTransactionDetailsForPostingPeriodList( - orderedNonInterestPostingTransactions); - - postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, - savingsAccountTransactionDetailsForPostingPeriod, this.currency, compoundingPeriodType, interestCalculationType, - interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, interestPostTransactions, - isInterestTransfer, minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, - overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, isUserPosting, financialYearBeginningMonth); - - periodStartingBalance = postingPeriod.closingBalance(); - - allPostingPeriods.add(postingPeriod); - } - - this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), - isTransferInterestToOtherAccount()); - - this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods); - } - - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } else { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - return allPostingPeriods; - } - - private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { - if (this.nominalAnnualInterestRateOverdraft == null) { - return BigDecimal.ZERO; - } - return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); - } - - public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { - return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); - } - - @SuppressWarnings("unused") - protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate upToInterestCalculationDate) { - return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); - } - - private boolean hasInterestCalculation() { - return !MathUtil.isEmpty(nominalAnnualInterestRate); - } - - private boolean hasOverdraftInterestCalculation() { - return isAllowOverdraft() && !MathUtil.isEmpty(getOverdraftLimit()) && !MathUtil.isEmpty(nominalAnnualInterestRateOverdraft); - } - - public List retrieveOrderedAccrualTransactions() { - return retrieveListOfTransactions().stream().filter(SavingsAccountTransaction::isAccrual) - .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); - } - - /** - * Transactions that should NOT count as part of this account's own balance history for interest - * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an - * input to it) AND, for accounts that transfer their interest out to a linked account - * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later - * sweeps that same interest back out (see TransferInterestToSavingsTasklet). - *

- * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here - * -- the debit half stays, permanently understating this account's balance (and therefore every - * subsequent period's interest) by that amount from the first transfer onward, even though the real - * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the - * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this - * account, so excluding those ids here closes that gap. - */ - protected Collection interestTransferWithdrawalTransactionIds() { - if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { - return Collections.emptyList(); - } - return this.savingsHelper.fetchPostInterestTransactionIds(getId()); - } - - /** - * The given transactions minus the interest-transfer withdrawal legs, exactly as - * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever - * sees them. - *

- * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's - * balance segment by walking backwards and ending the previous segment one day before the next - * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a - * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose - * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently - * cost the very first period one earning day. - */ - protected List excludeInterestTransferWithdrawals( - final List transactions) { - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - if (interestTransferWithdrawalIds.isEmpty()) { - return transactions; - } - return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) - .collect(Collectors.toList()); - } - - protected List retreiveOrderedNonInterestPostingTransactions() { - final List listOfTransactionsSorted = retrieveListOfTransactions(); - - final List orderedNonInterestPostingTransactions = new ArrayList<>(); - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - - for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { - if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction() - && !interestTransferWithdrawalIds.contains(transaction.getId())) { - orderedNonInterestPostingTransactions.add(transaction); - } - } - orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); - return orderedNonInterestPostingTransactions; - } - - protected List retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig() { - final List listOfTransactionsSorted = retrieveSortedTransactions(); - - final List orderedNonInterestPostingTransactions = new ArrayList<>(); - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - - for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { - if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction() - && !interestTransferWithdrawalIds.contains(transaction.getId())) { - orderedNonInterestPostingTransactions.add(transaction); - } - } - orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); - return orderedNonInterestPostingTransactions; - } - - protected List retrieveSortedTransactions() { - final List listOfTransactionsSorted = new ArrayList<>(); - listOfTransactionsSorted.addAll(this.savingsAccountTransactions); - final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); - Collections.sort(listOfTransactionsSorted, transactionComparator); - return listOfTransactionsSorted; - } - - protected List retrieveListOfTransactions() { - final List listOfTransactionsSorted = new ArrayList<>(); - listOfTransactionsSorted.addAll(this.transactions); - - final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); - Collections.sort(listOfTransactionsSorted, transactionComparator); - return listOfTransactionsSorted; - } - - protected void recalculateDailyBalances(final Money openingAccountBalance, final LocalDate interestPostingUpToDate, - final boolean backdatedTxnsAllowedTill, boolean postReversals) { - Money runningBalance = openingAccountBalance; - boolean calculateInterest = hasInterestCalculation() || hasOverdraftInterestCalculation(); - - List accountTransactionsSorted = null; - - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - - boolean isTransactionsModified = false; - for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { - if (transaction.isReversed() || transaction.isReversalTransaction()) { - transaction.zeroBalanceFields(); - } else { - Money overdraftAmount = Money.zero(this.currency); - Money transactionAmount = Money.zero(this.currency); - if ((transaction.isCredit() || transaction.isAmountRelease())) { - if (runningBalance.isLessThanZero()) { - Money diffAmount = transaction.getAmount(this.currency).plus(runningBalance); - if (diffAmount.isGreaterThanZero()) { - overdraftAmount = transaction.getAmount(this.currency).minus(diffAmount); - } else { - overdraftAmount = transaction.getAmount(this.currency); - } - } - transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); - } else if (transaction.isDebit() || transaction.isAmountOnHold()) { - if (runningBalance.isLessThanZero()) { - overdraftAmount = transaction.getAmount(this.currency); - } - transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); - } - - runningBalance = runningBalance.plus(transactionAmount); - transaction.setRunningBalance(runningBalance); - - if (MathUtil.isEmpty(overdraftAmount) && runningBalance.isLessThanZero() && !transaction.isAmountOnHold()) { - overdraftAmount = runningBalance.negated(); - } - if (!calculateInterest || transaction.getId() == null || transaction.getOverdraftAmount(this.currency).isZero()) { - transaction.setOverdraftAmount(overdraftAmount); - } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency)) - && !transaction.isAccrual()) { - SavingsAccountTransaction accountTransaction = SavingsAccountTransaction.copyTransaction(transaction); - if (transaction.isChargeTransaction()) { - Set chargesPaidBy = transaction.getSavingsAccountChargesPaid(); - final Set newChargePaidBy = new HashSet<>(); - chargesPaidBy.forEach(x -> newChargePaidBy - .add(SavingsAccountChargePaidBy.instance(accountTransaction, x.getSavingsAccountCharge(), x.getAmount()))); - accountTransaction.getSavingsAccountChargesPaid().addAll(newChargePaidBy); - } - SavingsAccountTransaction reversal = null; - transaction.reverse(); - if (postReversals) { - reversal = SavingsAccountTransaction.reversal(transaction); - } - if (MathUtil.isGreaterThanZero(overdraftAmount)) { - accountTransaction.setOverdraftAmount(overdraftAmount); - } - accountTransaction.setRunningBalance(runningBalance); - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(accountTransaction); - if (reversal != null) { - addTransactionToExisting(reversal); - } - } else { - addTransaction(accountTransaction); - if (reversal != null) { - addTransaction(reversal); - } - } - isTransactionsModified = true; - } - - } - } - - if (isTransactionsModified) { - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - } - resetAccountTransactionsEndOfDayBalances(accountTransactionsSorted, interestPostingUpToDate); - } - - protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, - final LocalDate interestPostingUpToDate) { - // loop over transactions in reverse - LocalDate endOfBalanceDate = interestPostingUpToDate; - for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { - final SavingsAccountTransaction transaction = accountTransactionsSorted.get(i); - if (transaction.isNotReversed() && !transaction.isReversalTransaction() - && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { - transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); - // this transactions transaction date is end of balance date for - // previous transaction. - endOfBalanceDate = transaction.getTransactionDate().minusDays(1); - } - } - } - - public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String refNo) { - return deposit(transactionDTO, SavingsAccountTransactionType.DEPOSIT, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - refNo); - } - - public SavingsAccountTransaction dividendPayout(final SavingsAccountTransactionDTO transactionDTO, - final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate) { - String refNo = null; - return deposit(transactionDTO, SavingsAccountTransactionType.DIVIDEND_PAYOUT, backdatedTxnsAllowedTill, - relaxingDaysConfigForPivotDate, refNo); - } - - public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, - final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String refNo) { - final String resourceTypeName = depositAccountType().resourceName(); - if (isNotActive()) { - final String defaultUserMessage = "Transaction is not allowed. Account is not active."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.account.is.not.active", defaultUserMessage, "transactionDate", - transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction date cannot be in the future."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg." + resourceTypeName + ".transaction.in.the.future", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { - final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getActivationDate().format(transactionDTO.getFormatter())).toArray(); - final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.before.activation.date", defaultUserMessage, "transactionDate", - defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - resourceTypeName); - - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_DEPOSIT, transactionDTO.getTransactionDate()); - - final Money amount = Money.of(this.currency, transactionDTO.getTransactionAmount()); - - final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(this, office(), transactionDTO.getPaymentDetail(), - transactionDTO.getTransactionDate(), amount, savingsAccountTransactionType, refNo); - - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(transaction); - } else { - this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); - addTransaction(transaction); - } - - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } - - return transaction; - } - - public void validatePivotDateTransaction(LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String resourceTypeName) { - if (backdatedTxnsAllowedTill) { - if (this.getSummary().getInterestPostedTillDate() != null && DateUtils.isBefore(transactionDate, - getSummary().getInterestPostedTillDate().minusDays(relaxingDaysConfigForPivotDate))) { - final Object[] defaultUserArgs = Arrays.asList(transactionDate, getActivationDate()).toArray(); - final String defaultUserMessage = "Transaction date cannot be before transactions pivot date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.before.pivot.date", defaultUserMessage, "transactionDate", - defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - - public LocalDate getActivationDate() { - return this.activatedOnDate; - } - - public AppUser getActivatedBy() { - return this.activatedBy; - } - - public LocalDate getWithdrawnOnDate() { - return this.withdrawnOnDate; - } - - public AppUser getWithdrawnBy() { - return this.withdrawnBy; - } - - // startInterestCalculationDate is set during migration so that there is no - // interference with interest posting of previous system - public LocalDate getStartInterestCalculationDate() { - LocalDate startInterestCalculationLocalDate = null; - if (this.startInterestCalculationDate != null) { - startInterestCalculationLocalDate = this.startInterestCalculationDate; - } else { - startInterestCalculationLocalDate = getActivationDate(); - } - return startInterestCalculationLocalDate; - } - - public void setStartInterestCalculationDate(LocalDate startInterestCalculationDate) { - this.startInterestCalculationDate = startInterestCalculationDate; - } - - public SavingsAccountTransaction withdraw(final SavingsAccountTransactionDTO transactionDTO, final boolean applyWithdrawFee, - final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate, String refNo) { - if (!isTransactionsAllowed()) { - - final String defaultUserMessage = "Transaction is not allowed. Account is not active."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.account.is.not.active", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction date cannot be in the future."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { - final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getActivationDate().format(transactionDTO.getFormatter())).toArray(); - final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.before.activation.date", - defaultUserMessage, "transactionDate", defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (isAccountLocked(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Withdrawal is not allowed. No withdrawals are allowed until after " - + getLockedInUntilDate().format(transactionDTO.getFormatter()); - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg.savingsaccount.transaction.withdrawals.blocked.during.lockin.period", defaultUserMessage, "transactionDate", - transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getLockedInUntilDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - "savingsaccount"); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_WITHDRAWAL, transactionDTO.getTransactionDate()); - - if (applyWithdrawFee) { - // auto pay withdrawal fee - payWithdrawalFee(transactionDTO.getTransactionAmount(), transactionDTO.getTransactionDate(), transactionDTO.getPaymentDetail(), - backdatedTxnsAllowedTill, refNo); - } - - final Money transactionAmountMoney = Money.of(this.currency, transactionDTO.getTransactionAmount()); - final SavingsAccountTransaction transaction = SavingsAccountTransaction.withdrawal(this, office(), - transactionDTO.getPaymentDetail(), transactionDTO.getTransactionDate(), transactionAmountMoney, refNo); - - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(transaction); - } else { - this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); - addTransaction(transaction); - } - - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } - return transaction; - } - - public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) { - BigDecimal result = BigDecimal.ZERO; - if (isWithdrawalFeeApplicableForTransfer()) { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isWithdrawalFee() && charge.isActive()) { - result = result.add(charge.calculateWithdralFeeAmount(transactionAmount), MoneyHelper.getMathContext()); - } - } - } - return result; - } - - private void payWithdrawalFee(final BigDecimal transactionAmount, final LocalDate transactionDate, final PaymentDetail paymentDetail, - final boolean backdatedTxnsAllowedTill, final String refNo) { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isWithdrawalFee() && charge.isActive()) { - - if (charge.getFreeWithdrawalCount() == null) { - charge.setFreeWithdrawalCount(0); - } - - if (charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction to - // specific paymentType - if (paymentDetail != null && paymentDetail.getPaymentType() != null - && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { - resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); - } - } else if (charge.isEnablePaymentType()) { // normal charge-transaction to specific paymentType - if (paymentDetail != null && paymentDetail.getPaymentType() != null - && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo); - } - } else if (!charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction - // irrespective of - // PaymentTypes. - resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); - - } else { // normal-withdraw - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo); - } - } - } - } - - private void resetFreeChargeDaysCount(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, - final String refNo) { - LocalDate resetDate = charge.getResetChargeDate(); - - Integer restartPeriod = charge.getRestartFrequency(); - if (charge.getRestartFrequencyEnum() == 2) { // calculation for months - LocalDate localDate = DateUtils.getBusinessLocalDate(); - - LocalDate resetLocalDate; - if (resetDate == null) { - resetLocalDate = this.activatedOnDate; - } else { - resetLocalDate = resetDate; - } - - LocalDate gapIntervalMonth = resetLocalDate.plusMonths(restartPeriod); - - YearMonth gapYearMonth = YearMonth.from(gapIntervalMonth); - YearMonth localYearMonth = YearMonth.from(localDate); - if (localYearMonth.isBefore(gapYearMonth)) { - countValidation(charge, transactionAmount, transactionDate, refNo); - } else { - discountCharge(1, charge); - } - } else { // calculation for days - long completedDays; - - if (resetDate == null) { - completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), this.activatedOnDate); - - } else { - completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), resetDate); - } - - int totalDays = (int) completedDays; - - if (totalDays < restartPeriod) { - countValidation(charge, transactionAmount, transactionDate, refNo); - } else { - discountCharge(1, charge); - } - } - } - - private void countValidation(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, - final String refNo) { - boolean backdatedTxnsAllowedTill = false; - if (charge.getFreeWithdrawalCount() < charge.getFrequencyFreeWithdrawalCharge()) { - final Integer count = charge.getFreeWithdrawalCount() + 1; - charge.setFreeWithdrawalCount(count); - charge.updateNoWithdrawalFee(); - } else { - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, refNo); - } - } - - private void discountCharge(Integer freeWithdrawalCount, SavingsAccountCharge charge) { - charge.setFreeWithdrawalCount(freeWithdrawalCount); - charge.setDiscountDueDate(DateUtils.getBusinessLocalDate()); - charge.updateNoWithdrawalFee(); - } - - public boolean isBeforeLastPostingPeriod(final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill) { - - boolean transactionBeforeLastInterestPosting = false; - - if (!backdatedTxnsAllowedTill) { - for (final SavingsAccountTransaction transaction : retrieveListOfTransactions()) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isAfter(transactionDate) && !transaction.isReversalTransaction()) { - transactionBeforeLastInterestPosting = true; - break; - } - } - } else { - if (this.summary.getInterestPostedTillDate() == null) { - return false; - } - transactionBeforeLastInterestPosting = DateUtils.isBefore(transactionDate, this.summary.getInterestPostedTillDate()); - } - - return transactionBeforeLastInterestPosting; - } - - public void validateAccountBalanceDoesNotBecomeNegative(final BigDecimal transactionAmount, final boolean isException, - final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { - - List transactionsSortedByDate = null; - - if (backdatedTxnsAllowedTill) { - transactionsSortedByDate = retrieveSortedTransactions(); - } else { - transactionsSortedByDate = retrieveListOfTransactions(); - } - - Money runningBalance = Money.zero(this.currency); - if (backdatedTxnsAllowedTill) { - runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - LocalDate lastSavingsDate = null; - final BigDecimal withdrawalFee = null; - for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { - if (transaction.isNotReversed() && transaction.isCredit() && !transaction.isReversalTransaction()) { - runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); - } else if (transaction.isNotReversed() && transaction.isDebit() && !transaction.isReversalTransaction()) { - runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); - } else { - continue; - } - - /* - * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the - * point in time the transaction was made: - */ - if (depositAccountOnHoldTransactions != null) { - for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { - // Compare the balance of the on hold: - if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) - && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { - if (onHoldTransaction.getTransactionType().isHold()) { - minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); - } else { - minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); - } - } - } - } - - // deal with potential minRequiredBalance and - // enforceMinRequiredBalance - if (!isException && transaction.canProcessBalanceCheck() && !isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } - lastSavingsDate = transaction.getTransactionDate(); - - } - - // In overdraft cases, minRequiredBalance can be in violation after - // interest posting - // and should be checked after processing all transactions - if (isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - - if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { - if (this.enforceMinRequiredBalance) { - if (runningBalance.minus(minRequiredBalance.plus(this.getSavingsHoldAmount())).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } else { - if (runningBalance.minus(this.getSavingsHoldAmount()).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } - } - } - - public void validateAccountBalanceDoesNotBecomeNegative(final String transactionAction, - final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { - - List transactionsSortedByDate = null; - BigDecimal transactionAmount = null; - if (backdatedTxnsAllowedTill) { - transactionsSortedByDate = retrieveSortedTransactions(); - } else { - transactionsSortedByDate = retrieveListOfTransactions(); - } - Money runningBalance = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - LocalDate lastSavingsDate = null; - for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { - - transactionAmount = transaction.getAmount(); - - if (transaction.isNotReversed() && transaction.isCredit()) { - runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); - } else if (transaction.isNotReversed() && transaction.isDebit()) { - runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); - } - - /* - * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the - * point in time the transaction was made: - */ - if (depositAccountOnHoldTransactions != null) { - for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { - // Compare the balance of the on hold: - if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) - && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { - if (onHoldTransaction.getTransactionType().isHold()) { - minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); - } else { - minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); - } - } - } - } - - // enforceMinRequiredBalance - if (transaction.canProcessBalanceCheck()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + transactionAction); - if (!this.allowOverdraft) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.going.negative"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - lastSavingsDate = transaction.getTransactionDate(); - } - - BigDecimal withdrawalFee = null; - if (isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - } - - public void validateAccountBalanceDoesNotViolateOverdraft(final List savingsAccountTransaction, - final BigDecimal amountPaid) { - if (savingsAccountTransaction != null && savingsAccountTransaction.size() > 0) { - SavingsAccountTransaction savingsAccountTransactionFirst = savingsAccountTransaction.get(0); - if (!this.allowOverdraft) { - if (savingsAccountTransactionFirst.getRunningBalance(this.currency).minus(amountPaid).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), null, amountPaid); - } - } - - } else { - if (!this.allowOverdraft) { - throw new InsufficientAccountBalanceException("transactionAmount", BigDecimal.ZERO, null, amountPaid); - } - } - } - - protected boolean isAccountLocked(final LocalDate transactionDate) { - return DateUtils.isBefore(transactionDate, getLockedInUntilDate()); - } - - public LocalDate getLockedInUntilDate() { - return this.lockedInUntilDate; - } - - public BigDecimal getAccountBalance() { - return this.summary.getAccountBalance(this.currency).getAmount(); - } - - public void modifyApplication(final JsonCommand command, final Map actualChanges) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); - this.modifyApplication(command, actualChanges, baseDataValidator); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public void modifyApplication(final JsonCommand command, final Map actualChanges, - final DataValidatorBuilder baseDataValidator) { - - final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); - final boolean isAllowedState = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus) - || SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus); - if (!isAllowedState) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - return; - } - // If modifying a rejected application, reset it back to pending approval - if (SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus)) { - this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); - this.rejectedOnDate = null; - this.rejectedBy = null; - actualChanges.put("status", SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue()); - } - - final String localeAsInput = command.locale(); - final String dateFormat = command.dateFormat(); - - if (command.isChangeInLocalDateParameterNamed(SavingsApiConstants.submittedOnDateParamName, getSubmittedOnDate())) { - final String newValueAsString = command.stringValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); - actualChanges.put(SavingsApiConstants.submittedOnDateParamName, newValueAsString); - actualChanges.put(SavingsApiConstants.localeParamName, localeAsInput); - actualChanges.put(SavingsApiConstants.dateFormatParamName, dateFormat); - this.submittedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); - } - if (command.isChangeInStringParameterNamed(SavingsApiConstants.accountNoParamName, this.accountNumber)) { - final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.accountNoParamName); - actualChanges.put(SavingsApiConstants.accountNoParamName, newValue); - this.accountNumber = StringUtils.defaultIfEmpty(newValue, null); - } - if (command.isChangeInStringParameterNamed(SavingsApiConstants.externalIdParamName, this.externalId.getValue())) { - final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.externalIdParamName); - actualChanges.put(SavingsApiConstants.externalIdParamName, newValue); - this.externalId = ExternalIdFactory.produce(newValue); - if (this.externalId.isEmpty() && TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled()) { - this.externalId = ExternalId.generate(); - } - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.clientIdParamName, clientId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.clientIdParamName); - actualChanges.put(SavingsApiConstants.clientIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.groupIdParamName, groupId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.groupIdParamName); - actualChanges.put(SavingsApiConstants.groupIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.productIdParamName, this.product.getId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.productIdParamName); - actualChanges.put(SavingsApiConstants.productIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.fieldOfficerIdParamName, hasSavingsOfficerId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.fieldOfficerIdParamName); - actualChanges.put(SavingsApiConstants.fieldOfficerIdParamName, newValue); - } - if (command.isChangeInBigDecimalParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName, - this.nominalAnnualInterestRate)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName); - actualChanges.put(SavingsApiConstants.nominalAnnualInterestRateParamName, newValue); - this.nominalAnnualInterestRate = newValue; - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName, - this.interestCompoundingPeriodType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName); - this.interestCompoundingPeriodType = newValue != null ? SavingsCompoundingInterestPeriodType.fromInt(newValue).getValue() - : newValue; - actualChanges.put(SavingsApiConstants.interestCompoundingPeriodTypeParamName, this.interestCompoundingPeriodType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName, - this.interestPostingPeriodType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName); - this.interestPostingPeriodType = newValue != null ? SavingsPostingInterestPeriodType.fromInt(newValue).getValue() : newValue; - actualChanges.put(SavingsApiConstants.interestPostingPeriodTypeParamName, this.interestPostingPeriodType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationTypeParamName); - this.interestCalculationType = newValue != null ? SavingsInterestCalculationType.fromInt(newValue).getValue() : newValue; - actualChanges.put(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, - this.interestCalculationDaysInYearType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName); - this.interestCalculationDaysInYearType = newValue != null - ? SavingsInterestCalculationDaysInYearType.fromInt(newValue).getValue() - : newValue; - actualChanges.put(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, this.interestCalculationDaysInYearType); - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(SavingsApiConstants.minRequiredOpeningBalanceParamName, - this.minRequiredOpeningBalance)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.minRequiredOpeningBalanceParamName); - actualChanges.put(SavingsApiConstants.minRequiredOpeningBalanceParamName, newValue); - actualChanges.put("locale", localeAsInput); - this.minRequiredOpeningBalance = Money.of(this.currency, newValue).getAmount(); - } - if (command.isChangeInIntegerParameterNamedDefaultingZeroToNull(SavingsApiConstants.lockinPeriodFrequencyParamName, - this.lockinPeriodFrequency)) { - final Integer newValue = command - .integerValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.lockinPeriodFrequencyParamName); - actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyParamName, newValue); - actualChanges.put("locale", localeAsInput); - this.lockinPeriodFrequency = newValue; - } - - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, - this.lockinPeriodFrequencyType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName); - actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, newValue); - this.lockinPeriodFrequencyType = newValue != null ? SavingsPeriodFrequencyType.fromInt(newValue).getValue() : newValue; - } - - // set period type to null if frequency is null - if (this.lockinPeriodFrequency == null) { - this.lockinPeriodFrequencyType = null; - } - - if (command.isChangeInBooleanParameterNamed(withdrawalFeeForTransfersParamName, this.withdrawalFeeApplicableForTransfer)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withdrawalFeeForTransfersParamName); - actualChanges.put(withdrawalFeeForTransfersParamName, newValue); - this.withdrawalFeeApplicableForTransfer = newValue; - } - - // charges - final String chargesParamName = "charges"; - if (command.hasParameter(chargesParamName)) { - final JsonArray jsonArray = command.arrayOfParameterNamed(chargesParamName); - if (jsonArray != null) { - actualChanges.put(chargesParamName, command.jsonFragment(chargesParamName)); - } - } - - if (command.isChangeInBooleanParameterNamed(allowOverdraftParamName, this.allowOverdraft)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(allowOverdraftParamName); - actualChanges.put(allowOverdraftParamName, newValue); - this.allowOverdraft = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(overdraftLimitParamName, this.overdraftLimit)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(overdraftLimitParamName); - actualChanges.put(overdraftLimitParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.overdraftLimit = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(nominalAnnualInterestRateOverdraftParamName, - this.nominalAnnualInterestRateOverdraft)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(nominalAnnualInterestRateOverdraftParamName); - actualChanges.put(nominalAnnualInterestRateOverdraftParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.nominalAnnualInterestRateOverdraft = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minOverdraftForInterestCalculationParamName, - this.minOverdraftForInterestCalculation)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(minOverdraftForInterestCalculationParamName); - actualChanges.put(minOverdraftForInterestCalculationParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.minOverdraftForInterestCalculation = newValue; - } - - if (!this.allowOverdraft) { - this.overdraftLimit = null; - this.nominalAnnualInterestRateOverdraft = null; - this.minOverdraftForInterestCalculation = null; - } - - if (command.isChangeInBooleanParameterNamed(enforceMinRequiredBalanceParamName, this.enforceMinRequiredBalance)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(enforceMinRequiredBalanceParamName); - actualChanges.put(enforceMinRequiredBalanceParamName, newValue); - this.enforceMinRequiredBalance = newValue; - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minRequiredBalanceParamName, this.minRequiredBalance)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(minRequiredBalanceParamName); - actualChanges.put(minRequiredBalanceParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.minRequiredBalance = newValue; - } - if (command.isChangeInBooleanParameterNamed(lienAllowedParamName, this.lienAllowed)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(lienAllowedParamName); - actualChanges.put(lienAllowedParamName, newValue); - this.lienAllowed = newValue; - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(maxAllowedLienLimitParamName, this.maxAllowedLienLimit)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(maxAllowedLienLimitParamName); - actualChanges.put(maxAllowedLienLimitParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.maxAllowedLienLimit = newValue; - } - if (command.isChangeInBooleanParameterNamed(withHoldTaxParamName, this.withHoldTax)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); - actualChanges.put(withHoldTaxParamName, newValue); - this.withHoldTax = newValue; - if (this.withHoldTax && this.taxGroup == null) { - baseDataValidator.reset().parameter(withHoldTaxParamName).failWithCode("not.supported.for.this.account"); - } - } - - if (command.isChangeInBooleanParameterNamed(introducerRequiredParamName, this.introducerRequired)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(introducerRequiredParamName); - actualChanges.put(introducerRequiredParamName, newValue); - this.introducerRequired = newValue; - } - - if (command.isChangeInBooleanParameterNamed(shareLinkingEnabledParamName, this.shareLinkingEnabled)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(shareLinkingEnabledParamName); - actualChanges.put(shareLinkingEnabledParamName, newValue); - this.shareLinkingEnabled = newValue; - } - - // CBS extended fields mapping during modify application - final String accountTypeParam = "accountType"; - if (command.isChangeInStringParameterNamed(accountTypeParam, this.cbsAccountType)) { - final String newValue = command.stringValueOfParameterNamed(accountTypeParam); - actualChanges.put(accountTypeParam, newValue); - this.cbsAccountType = newValue; - } - - final String customerSegmentParam = "customerSegment"; - if (command.isChangeInStringParameterNamed(customerSegmentParam, this.customerSegment)) { - final String newValue = command.stringValueOfParameterNamed(customerSegmentParam); - actualChanges.put(customerSegmentParam, newValue); - this.customerSegment = newValue; - } - - final String accountStatusParam = "accountStatus"; - if (command.isChangeInStringParameterNamed(accountStatusParam, this.accountStatus)) { - final String newValue = command.stringValueOfParameterNamed(accountStatusParam); - actualChanges.put(accountStatusParam, newValue); - this.accountStatus = newValue; - } - - final String modeOfOperationParam = "modeOfOperation"; - if (command.isChangeInStringParameterNamed(modeOfOperationParam, this.modeOfOperation)) { - final String newValue = command.stringValueOfParameterNamed(modeOfOperationParam); - actualChanges.put(modeOfOperationParam, newValue); - this.modeOfOperation = newValue; - } - - final String specialCategoryParam = "specialCategory"; - if (command.isChangeInStringParameterNamed(specialCategoryParam, this.specialCategory)) { - final String newValue = command.stringValueOfParameterNamed(specialCategoryParam); - actualChanges.put(specialCategoryParam, newValue); - this.specialCategory = newValue; - } - - final String weakerSectionParam = "weakerSection"; - if (command.isChangeInStringParameterNamed(weakerSectionParam, this.weakerSection)) { - final String newValue = command.stringValueOfParameterNamed(weakerSectionParam); - actualChanges.put(weakerSectionParam, newValue); - this.weakerSection = newValue; - } - - final String nominationFacilityParam = "nominationFacility"; - if (command.isChangeInBooleanParameterNamed(nominationFacilityParam, this.nominationFacility)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(nominationFacilityParam); - actualChanges.put(nominationFacilityParam, newValue); - this.nominationFacility = newValue; - } - - final String autoDebitFacilityParam = "autoDebitFacility"; - if (command.isChangeInBooleanParameterNamed(autoDebitFacilityParam, this.autoDebitFacility)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(autoDebitFacilityParam); - actualChanges.put(autoDebitFacilityParam, newValue); - this.autoDebitFacility = newValue; - } - - final String isSeniorCitizenParam = "isSeniorCitizen"; - if (command.isChangeInBooleanParameterNamed(isSeniorCitizenParam, this.isSeniorCitizen)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(isSeniorCitizenParam); - actualChanges.put(isSeniorCitizenParam, newValue); - this.isSeniorCitizen = newValue; - } - - final String seniorCitizenInterestRateParam = "seniorCitizenInterestRate"; - if (command.isChangeInBigDecimalParameterNamed(seniorCitizenInterestRateParam, this.seniorCitizenInterestRate)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(seniorCitizenInterestRateParam); - actualChanges.put(seniorCitizenInterestRateParam, newValue); - this.seniorCitizenInterestRate = newValue; - if (newValue != null && newValue.compareTo(BigDecimal.ZERO) > 0) { - updateNominalAnnualInterestRate(newValue); - } - } - - final String guardianNameParam = "guardianName"; - if (command.isChangeInStringParameterNamed(guardianNameParam, this.guardianName)) { - final String newValue = command.stringValueOfParameterNamed(guardianNameParam); - actualChanges.put(guardianNameParam, newValue); - this.guardianName = newValue; - } - - final String guardianCifNumberParam = "guardianCifNumber"; - if (command.isChangeInStringParameterNamed(guardianCifNumberParam, this.guardianCifNumber)) { - final String newValue = command.stringValueOfParameterNamed(guardianCifNumberParam); - actualChanges.put(guardianCifNumberParam, newValue); - this.guardianCifNumber = newValue; - } - - final String guardianRelationshipParam = "guardianRelationship"; - if (command.isChangeInStringParameterNamed(guardianRelationshipParam, this.guardianRelationship)) { - final String newValue = command.stringValueOfParameterNamed(guardianRelationshipParam); - actualChanges.put(guardianRelationshipParam, newValue); - this.guardianRelationship = newValue; - } - - final String minorDateOfBirthParam = "minorDateOfBirth"; - if (command.isChangeInLocalDateParameterNamed(minorDateOfBirthParam, this.minorDateOfBirth)) { - final LocalDate newValue = command.localDateValueOfParameterNamed(minorDateOfBirthParam); - actualChanges.put(minorDateOfBirthParam, newValue); - this.minorDateOfBirth = newValue; - } - - final String guardianClientIdParam = "guardianClientId"; - if (command.isChangeInLongParameterNamed(guardianClientIdParam, this.guardianClientId)) { - final Long newValue = command.longValueOfParameterNamed(guardianClientIdParam); - actualChanges.put(guardianClientIdParam, newValue); - this.guardianClientId = newValue; - } - - final String introducerIdParam = "introducerId"; - if (command.isChangeInLongParameterNamed(introducerIdParam, this.introducerClientId)) { - final Long newValue = command.longValueOfParameterNamed(introducerIdParam); - actualChanges.put(introducerIdParam, newValue); - this.introducerClientId = newValue; - } - - final String introducerNameParam = "introducerName"; - if (command.isChangeInStringParameterNamed(introducerNameParam, this.introducerName)) { - final String newValue = command.stringValueOfParameterNamed(introducerNameParam); - actualChanges.put(introducerNameParam, newValue); - this.introducerName = newValue; - } - - final String introducerCifNumberParam = "introducerCifNumber"; - if (command.isChangeInStringParameterNamed(introducerCifNumberParam, this.introducerCifNumber)) { - final String newValue = command.stringValueOfParameterNamed(introducerCifNumberParam); - actualChanges.put(introducerCifNumberParam, newValue); - this.introducerCifNumber = newValue; - } - - final String linkedAccountIdParam = "linkedAccountId"; - if (command.isChangeInLongParameterNamed(linkedAccountIdParam, this.linkedAccountId)) { - final Long newValue = command.longValueOfParameterNamed(linkedAccountIdParam); - actualChanges.put(linkedAccountIdParam, newValue); - this.linkedAccountId = newValue; - } - - final String linkedAccountTypeParam = "linkedAccountType"; - if (command.isChangeInStringParameterNamed(linkedAccountTypeParam, this.linkedAccountType)) { - final String newValue = command.stringValueOfParameterNamed(linkedAccountTypeParam); - actualChanges.put(linkedAccountTypeParam, newValue); - this.linkedAccountType = newValue; - } - - final String linkedAccountNoParam = "linkedAccountNo"; - if (command.isChangeInStringParameterNamed(linkedAccountNoParam, this.linkedAccountNo)) { - final String newValue = command.stringValueOfParameterNamed(linkedAccountNoParam); - actualChanges.put(linkedAccountNoParam, newValue); - this.linkedAccountNo = newValue; - } - - final String accountFacilityConfigParam = "accountFacilityConfig"; - if (command.hasParameter(accountFacilityConfigParam)) { - final com.google.gson.JsonElement parsedJson = command.parsedJson(); - if (parsedJson != null && parsedJson.isJsonObject()) { - final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); - if (jsonObj.has(accountFacilityConfigParam) && !jsonObj.get(accountFacilityConfigParam).isJsonNull() - && jsonObj.get(accountFacilityConfigParam).isJsonObject()) { - final com.google.gson.JsonObject configObj = jsonObj.get(accountFacilityConfigParam).getAsJsonObject(); - if (this.facilityConfig == null) { - this.facilityConfig = new SavingsAccountFacilityConfig(this); - } - this.facilityConfig.updateFrom(configObj); - actualChanges.put(accountFacilityConfigParam, configObj.toString()); - } - } - } - - final String jointHoldersParam = "jointHolders"; - if (command.hasParameter(jointHoldersParam)) { - final com.google.gson.JsonElement parsedJson = command.parsedJson(); - if (parsedJson != null && parsedJson.isJsonObject()) { - final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); - if (jsonObj.has(jointHoldersParam) && !jsonObj.get(jointHoldersParam).isJsonNull() - && jsonObj.get(jointHoldersParam).isJsonArray()) { - final com.google.gson.JsonArray holdersArray = jsonObj.get(jointHoldersParam).getAsJsonArray(); - final List holders = new ArrayList<>(); - for (int i = 0; i < holdersArray.size(); i++) { - final com.google.gson.JsonObject h = holdersArray.get(i).getAsJsonObject(); - final Long clientId = h.has("clientId") && !h.get("clientId").isJsonNull() - ? h.get("clientId").getAsLong() : null; - final String cifNumber = h.has("cifNumber") && !h.get("cifNumber").isJsonNull() - ? h.get("cifNumber").getAsString() : null; - final String displayName = h.has("displayName") && !h.get("displayName").isJsonNull() - ? h.get("displayName").getAsString() : null; - final BigDecimal sharePercentage = h.has("sharePercentage") && !h.get("sharePercentage").isJsonNull() - ? h.get("sharePercentage").getAsBigDecimal() : null; - final boolean isPrimary = h.has("isPrimary") && h.get("isPrimary").getAsBoolean(); - String signingAuthority = h.has("signingAuthority") && !h.get("signingAuthority").isJsonNull() - ? h.get("signingAuthority").getAsString() : null; - String role = h.has("role") && !h.get("role").isJsonNull() - ? h.get("role").getAsString() : null; - - if (signingAuthority == null || signingAuthority.trim().isEmpty()) { - signingAuthority = isPrimary ? "Mandatory" : "Optional"; - } - if (role == null || role.trim().isEmpty()) { - role = isPrimary ? "Primary Account Holder" : "Co-Holder"; - } - - final boolean isMandatory = h.has("isMandatory") && h.get("isMandatory").getAsBoolean(); - holders.add(new SavingsAccountJointHolder(this, clientId, cifNumber, displayName, - sharePercentage, signingAuthority, role, isPrimary, isMandatory)); - } - this.jointHolders.clear(); - this.jointHolders.addAll(holders); - actualChanges.put(jointHoldersParam, holdersArray.toString()); - } - } - } - - validateLockinDetails(baseDataValidator); - esnureOverdraftLimitsSetForOverdraftAccounts(); - } - - /** - * If overdrafts are allowed and the overdraft limit is not set, set the same to Zero - **/ - private void esnureOverdraftLimitsSetForOverdraftAccounts() { - - this.overdraftLimit = this.overdraftLimit == null ? BigDecimal.ZERO : this.overdraftLimit; - this.nominalAnnualInterestRateOverdraft = this.nominalAnnualInterestRateOverdraft == null ? BigDecimal.ZERO - : this.nominalAnnualInterestRateOverdraft; - this.minOverdraftForInterestCalculation = this.minOverdraftForInterestCalculation == null ? BigDecimal.ZERO - : this.minOverdraftForInterestCalculation; - } - - private void validateLockinDetails(final DataValidatorBuilder baseDataValidator) { - - /* - * final List dataValidationErrors = new ArrayList(); final - * DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - * .resource(resourceName); - */ - - if (this.lockinPeriodFrequency == null) { - baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() - .inMinMaxRange(0, 3); - - if (this.lockinPeriodFrequencyType != null) { - baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequency).ignoreIfNull() - .integerZeroOrGreater(); - } - } else { - baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequencyType) - .integerZeroOrGreater(); - baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() - .inMinMaxRange(0, 3); - } - } - - public Map deriveAccountingBridgeData(final String currencyCode, final Set existingTransactionIds, - final Set existingReversedTransactionIds, boolean isAccountTransfer, final boolean backdatedTxnsAllowedTill) { - - final Map accountingBridgeData = new LinkedHashMap<>(); - accountingBridgeData.put("savingsId", getId()); - accountingBridgeData.put("savingsProductId", productId()); - accountingBridgeData.put("currencyCode", currencyCode); - accountingBridgeData.put("officeId", officeId()); - accountingBridgeData.put("cashBasedAccountingEnabled", isCashBasedAccountingEnabledOnSavingsProduct()); - accountingBridgeData.put("accrualBasedAccountingEnabled", isAccrualBasedAccountingEnabledOnSavingsProduct()); - accountingBridgeData.put("isAccountTransfer", isAccountTransfer); - accountingBridgeData.put("depositAccountType", depositAccountType().getValue()); - - final List> newSavingsTransactions = new ArrayList<>(); - - List trans = null; - - if (backdatedTxnsAllowedTill) { - trans = getSavingsAccountTransactionsWithPivotConfig(); - } else { - trans = getTransactions(); - } - - // Adding new transactions to the array - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed() && !existingReversedTransactionIds.contains(transaction.getId())) { - newSavingsTransactions.add(transaction.toMapData(currencyCode)); - } else if (!existingTransactionIds.contains(transaction.getId())) { - newSavingsTransactions.add(transaction.toMapData(currencyCode)); - } - } - - accountingBridgeData.put("newSavingsTransactions", newSavingsTransactions); - return accountingBridgeData; - } - - public Collection findExistingTransactionIds() { - final Collection ids = new ArrayList<>(); - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - ids.add(transaction.getId()); - } - return ids; - } - - public Collection findCurrentTransactionIdsWithPivotDateConfig() { - - final Collection ids = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - ids.add(transaction.getId()); - } - return ids; - } - - public Collection findExistingReversedTransactionIds() { - - final Collection ids = new ArrayList<>(); - List trans = getTransactions(); - // time consuming - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed()) { - ids.add(transaction.getId()); - } - } - - return ids; - } - - public Collection findCurrentReversedTransactionIdsWithPivotDateConfig() { - final Collection ids = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - // time consuming - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed()) { - ids.add(transaction.getId()); - } - } - return ids; - } - - public void update(final Client client) { - this.client = client; - } - - public void update(final Group group) { - this.group = group; - } - - public void update(final SavingsProduct product) { - this.product = product; - this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); - } - - public void update(final Staff savingsOfficer) { - this.savingsOfficer = savingsOfficer; - } - - public void updateAccountNo(final String newAccountNo) { - this.accountNumber = newAccountNo; - this.accountNumberRequiresAutoGeneration = false; - } - - public boolean isAccountNumberRequiresAutoGeneration() { - return this.accountNumberRequiresAutoGeneration; - } - - public Long productId() { - return this.product.getId(); - } - - public SavingsProduct savingsProduct() { - return this.product; - } - - private Boolean isCashBasedAccountingEnabledOnSavingsProduct() { - return this.product.isCashBasedAccountingEnabled(); - } - - private Boolean isAccrualBasedAccountingEnabledOnSavingsProduct() { - return this.product.isAccrualBasedAccountingEnabled(); - } - - public Long officeId() { - Long officeId = null; - if (this.client != null) { - officeId = this.client.officeId(); - } else if (this.group != null) { - officeId = this.group.officeId(); - } - return officeId; - } - - public Office office() { - Office office = null; - if (this.client != null) { - office = this.client.getOffice(); - } else if (this.group != null) { - office = this.group.getOffice(); - } - return office; - } - - public Staff getSavingsOfficer() { - return this.savingsOfficer; - } - - public Boolean getEnforceMinRequiredBalance() { - return this.enforceMinRequiredBalance; - } - - public Boolean getLienAllowed() { - return this.lienAllowed; - } - - public void unassignSavingsOfficer() { - this.savingsOfficer = null; - } - - public void assignSavingsOfficer(final Staff fieldOfficer) { - this.savingsOfficer = fieldOfficer; - } - - public Long clientId() { - Long id = null; - if (this.client != null) { - id = this.client.getId(); - } - return id; - } - - public Long groupId() { - Long id = null; - if (this.group != null) { - id = this.group.getId(); - } - return id; - } - - public GroupSavingsIndividualMonitoring getGsim() { - return gsim; - } - - public Long getSavingsProductId() { - return this.savingsProduct().getId(); - } - - public void setGsim(GroupSavingsIndividualMonitoring gsim) { - this.gsim = gsim; - } - - public Long hasSavingsOfficerId() { - Long id = null; - if (this.savingsOfficer != null) { - id = this.savingsOfficer.getId(); - } - return id; - } - - public boolean hasSavingsOfficer(final Staff fromSavingsOfficer) { - - boolean matchesCurrentSavingsOfficer = false; - if (this.savingsOfficer != null) { - matchesCurrentSavingsOfficer = this.savingsOfficer.identifiedBy(fromSavingsOfficer); - } else { - matchesCurrentSavingsOfficer = fromSavingsOfficer == null; - } - return matchesCurrentSavingsOfficer; - } - - public void reassignSavingsOfficer(final Staff newSavingsOfficer, final LocalDate assignmentDate) { - final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); - final SavingsOfficerAssignmentHistory lastAssignmentRecord = findLastAssignmentHistoryRecord(newSavingsOfficer); - - // assignment date should not be less than savings account submitted date - if (DateUtils.isBefore(assignmentDate, getSubmittedOnDate())) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate.toString() - + ") cannot be before savings submitted date (" + getSubmittedOnDate().toString() + ")."; - throw new SavingsOfficerAssignmentDateException("cannot.be.before.savings.submitted.date", errorMessage, assignmentDate, - getSubmittedOnDate()); - } else if (lastAssignmentRecord != null && lastAssignmentRecord.isBeforeEndDate(assignmentDate)) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate - + ") cannot be before previous Savings Officer unassigned date (" + lastAssignmentRecord.getEndDate() + ")."; - throw new SavingsOfficerAssignmentDateException("cannot.be.before.previous.unassignement.date", errorMessage, assignmentDate, - lastAssignmentRecord.getEndDate()); - } else if (DateUtils.isDateInTheFuture(assignmentDate)) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + ") cannot be in the future."; - throw new SavingsOfficerAssignmentDateException("cannot.be.a.future.date", errorMessage, assignmentDate); - } else if (latestHistoryRecord != null && this.savingsOfficer.identifiedBy(newSavingsOfficer)) { - latestHistoryRecord.setStartDate(assignmentDate); - } else if (latestHistoryRecord != null && latestHistoryRecord.matchesStartDateOf(assignmentDate)) { - latestHistoryRecord.setSavingsOfficer(newSavingsOfficer); - this.savingsOfficer = newSavingsOfficer; - } else if (latestHistoryRecord != null && latestHistoryRecord.isBeforeStartDate(assignmentDate)) { - final String errorMessage = "Savings account with identifier " + getId() + " was already assigned before date " - + assignmentDate; - throw new SavingsOfficerAssignmentDateException("is.before.last.assignment.date", errorMessage, getId(), assignmentDate); - } else { - if (latestHistoryRecord != null) { - // savings officer correctly changed from previous savings - // officer to - // new savings officer - latestHistoryRecord.setEndDate(assignmentDate); - } - this.savingsOfficer = newSavingsOfficer; - if (isNotSubmittedAndPendingApproval()) { - final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, - this.savingsOfficer, assignmentDate); - this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); - } - } - } - - private SavingsOfficerAssignmentHistory findLastAssignmentHistoryRecord(final Staff newSavingsOfficer) { - SavingsOfficerAssignmentHistory lastAssignmentRecordLatestEndDate = null; - for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { - if (historyRecord.isCurrentRecord() && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { - lastAssignmentRecordLatestEndDate = historyRecord; - break; - } - - if (lastAssignmentRecordLatestEndDate == null) { - lastAssignmentRecordLatestEndDate = historyRecord; - } else if (historyRecord.isBeforeEndDate(lastAssignmentRecordLatestEndDate.getEndDate()) - && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { - lastAssignmentRecordLatestEndDate = historyRecord; - } - } - return lastAssignmentRecordLatestEndDate; - } - - public LocalDate getSubmittedOnDate() { - return submittedOnDate; - } - - public LocalDate getApprovedOnDate() { - return approvedOnDate; - } - - public LocalDate getRejectedOnDate() { - return rejectedOnDate; - } - - public AppUser getRejectedBy() { - return this.rejectedBy; - } - - public void removeSavingsOfficer(final LocalDate unassignDate) { - final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); - - if (latestHistoryRecord != null) { - validateUnassignDate(latestHistoryRecord, unassignDate); - latestHistoryRecord.setEndDate(unassignDate); - } - this.savingsOfficer = null; - } - - private SavingsOfficerAssignmentHistory findLatestIncompleteHistoryRecord() { - SavingsOfficerAssignmentHistory latestRecordWithNoEndDate = null; - for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { - if (historyRecord.isCurrentRecord()) { - latestRecordWithNoEndDate = historyRecord; - break; - } - } - return latestRecordWithNoEndDate; - } - - private void validateUnassignDate(final SavingsOfficerAssignmentHistory latestHistoryRecord, final LocalDate unassignDate) { - if (DateUtils.isBefore(unassignDate, latestHistoryRecord.getStartDate())) { - final String errorMessage = "The Savings officer Unassign date(" + unassignDate + ") cannot be before its assignment date (" - + latestHistoryRecord.getStartDate() + ")."; - throw new SavingsOfficerUnassignmentDateException("cannot.be.before.assignment.date", errorMessage, getId(), - getSavingsOfficer().getId(), latestHistoryRecord.getStartDate(), unassignDate); - } else if (DateUtils.isDateInTheFuture(unassignDate)) { - final String errorMessage = "The Savings Officer Unassign date (" + unassignDate + ") cannot be in the future."; - throw new SavingsOfficerUnassignmentDateException("cannot.be.a.future.date", errorMessage, unassignDate); - } - } - - public MonetaryCurrency getCurrency() { - return this.currency; - } - - public void validateNewApplicationState(final String resourceName) { - // validateWithdrawalFeeDetails(); - // validateAnnualFeeDetails(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(resourceName + SavingsApiConstants.summitalAction); - - validateLockinDetails(baseDataValidator); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - final LocalDate submittedOn = getSubmittedOnDate(); - if (DateUtils.isDateInTheFuture(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(submittedOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - } - - if (this.client != null && this.client.isActivatedAfter(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.client.getActivationDate()) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - } else if (this.group != null && this.group.isActivatedAfter(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.group.getActivationDate()) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - } - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public Client getClient() { - return this.client; - } - - public BigDecimal getNominalAnnualInterestRate() { - return this.nominalAnnualInterestRate; - } - - public Integer getInterestCompoundingPeriodType() { - return this.interestCompoundingPeriodType; - } - - public Integer getInterestPostingPeriodType() { - return this.interestPostingPeriodType; - } - - public Integer getInterestCalculationType() { - return this.interestCalculationType; - } - - public BigDecimal getNominalAnnualInterestRateOverdraft() { - return this.nominalAnnualInterestRateOverdraft; - } - - public Map approveApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.approvalAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.APPROVED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - // only do below if status has changed in the 'approval' case - final LocalDate approvedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); - final String approvedOnDateChange = command.stringValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); - - this.approvedOnDate = approvedOn; - this.approvedBy = currentUser; - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.approvedOnDateParamName, approvedOnDateChange); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(approvedOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(approvedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_APPROVED, approvedOn); - - // FIXME - kw - support field officer history for savings accounts - // if (this.fieldOfficer != null) { - // final LoanOfficerAssignmentHistory loanOfficerAssignmentHistory = - // LoanOfficerAssignmentHistory.createNew(this, - // this.fieldOfficer, approvedOn); - // this.loanOfficerHistory.add(loanOfficerAssignmentHistory); - // } - if (this.savingsOfficer != null) { - final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, - this.savingsOfficer, approvedOn); - this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); - } - return actualChanges; - } - - public Map undoApplicationApproval() { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.undoApprovalAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - this.approvedOnDate = null; - this.approvedBy = null; - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - actualChanges.put(SavingsApiConstants.approvedOnDateParamName, ""); - - // FIXME - kw - support field officer history for savings accounts - // this.loanOfficerHistory.clear(); - - return actualChanges; - } - - public void undoTransaction(final Long transactionId) { - - SavingsAccountTransaction transactionToUndo = null; - for (final SavingsAccountTransaction transaction : this.transactions) { - if (transaction.isIdentifiedBy(transactionId)) { - transactionToUndo = transaction; - } - } - - if (transactionToUndo == null) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); - } - } - } - } - - public void undoSavingsTransaction(final Long transactionId) { - - SavingsAccountTransaction transactionToUndo = null; - for (final SavingsAccountTransaction transaction : this.savingsAccountTransactions) { - if (transaction.isIdentifiedBy(transactionId)) { - transactionToUndo = transaction; - } - } - - if (transactionToUndo == null) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } - } - } - } - - public void undoTransaction(final SavingsAccountTransaction transactionToUndo) { - - if (transactionToUndo.isReversed()) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionToUndo.getId()); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } - } - } - } - - private LocalDate findLatestAnnualFeeTransactionDueDate() { - LocalDate nextDueDate = null; - - LocalDate lastAnnualFeeTransactionDate = null; - for (final SavingsAccountTransaction transaction : retreiveOrderedNonInterestPostingTransactions()) { - if (transaction.isAnnualFeeAndNotReversed()) { - LocalDate transactionDate = transaction.getTransactionDate(); - if (lastAnnualFeeTransactionDate == null) { - lastAnnualFeeTransactionDate = transactionDate; - nextDueDate = lastAnnualFeeTransactionDate; - } - if (DateUtils.isAfter(transactionDate, lastAnnualFeeTransactionDate)) { - lastAnnualFeeTransactionDate = transactionDate; - nextDueDate = lastAnnualFeeTransactionDate; - } - } - } - return nextDueDate; - } - - public void validateAccountBalanceDoesNotBecomeNegativeMinimal(final BigDecimal transactionAmount, final boolean isException) { - // final List transactionsSortedByDate = retrieveListOfTransactions(); - Money runningBalance = this.summary.getAccountBalance(getCurrency()); - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - final BigDecimal withdrawalFee = null; - - // check last txn date - - // In overdraft cases, minRequiredBalance can be in violation after interest posting - // and should be checked after processing all transactions - if (!isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - - if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { - if (runningBalance.minus(this.getSavingsHoldAmount()).minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - } - - public Map rejectApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.rejectAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.REJECTED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - final LocalDate rejectedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); - final String rejectedOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); - - this.rejectedOnDate = rejectedOn; - this.rejectedBy = currentUser; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = rejectedOn; - this.closedBy = currentUser; - - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.rejectedOnDateParamName, rejectedOnAsString); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, rejectedOnAsString); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(rejectedOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(rejectedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(rejectedOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_REJECTED, rejectedOn); - - return actualChanges; - } - - public Map applicantWithdrawsFromApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.withdrawnByApplicantAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.WITHDRAWN_BY_APPLICANT.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - final LocalDate withdrawnOn = command.localDateValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); - final String withdrawnOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = withdrawnOn; - this.withdrawnBy = currentUser; - this.closedOnDate = withdrawnOn; - this.closedBy = currentUser; - - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.withdrawnOnDateParamName, withdrawnOnAsString); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, withdrawnOnAsString); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(withdrawnOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(withdrawnOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(withdrawnOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_WITHDRAWAL_BY_CUSTOMER, withdrawnOn); - - return actualChanges; - } - - public Map activate(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.activateAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate activationDate = command.localDateValueOfParameterNamed(SavingsApiConstants.activatedOnDateParamName); - - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.activatedOnDateParamName, activationDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - this.activatedOnDate = activationDate; - this.activatedBy = currentUser; - this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); - - /* - * if (annualFeeSettingsSet()) { updateToNextAnnualFeeDueDateFrom(getActivationLocalDate()); } - */ - if (this.client != null && this.client.isActivatedAfter(activationDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(this.client.getActivationDate()); - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (this.group != null && this.group.isActivatedAfter(activationDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(this.client.getActivationDate()); - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.group.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final LocalDate approvalDate = getApprovedOnDate(); - if (DateUtils.isBefore(activationDate, approvalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(approvalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.approval.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (DateUtils.isAfterBusinessDate(activationDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(activationDate) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_ACTIVATE, activationDate); - - return actualChanges; - } - - public void processAccountUponActivation(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, - final Integer financialYearBeginningMonth) { - // update annual fee due date - for (SavingsAccountCharge charge : this.charges()) { - charge.updateToNextDueDateFrom(getActivationDate()); - } - - // auto pay the activation time charges (No need of checking the pivot date config) - this.payActivationCharges(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, false); - // TODO : AA add activation charges to actual changes list - } - - public Money activateWithBalance() { - // Activation never deposits money -- funding happens strictly through a separate deposit - // transaction (posted by a clerk) after the account is already active, regardless of any - // minimum required opening balance the product has configured. - return Money.zero(this.currency); - } - - public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - this.approvedOnDate = appliedonDate; - this.approvedBy = appliedBy; - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - this.activatedOnDate = appliedonDate; - this.activatedBy = appliedBy; - this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); - } - - private void payActivationCharges(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final boolean backdatedTxnsAllowedTill) { - boolean isSavingsChargeApplied = false; - boolean postReversals = false; - UUID refNo = UUID.randomUUID(); - for (SavingsAccountCharge savingsAccountCharge : this.charges()) { - if (savingsAccountCharge.isSavingsActivation()) { - isSavingsChargeApplied = true; - payCharge(savingsAccountCharge, savingsAccountCharge.getAmountOutstanding(getCurrency()), getActivationDate(), - backdatedTxnsAllowedTill, refNo.toString()); - } - } - - if (isSavingsChargeApplied) { - final MathContext mc = MathContext.DECIMAL64; - boolean isInterestTransfer = false; - LocalDate postInterestAsOnDate = null; - if (this.isBeforeLastPostingPeriod(getActivationDate(), backdatedTxnsAllowedTill)) { - final LocalDate today = DateUtils.getBusinessLocalDate(); - this.postInterest(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, - postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); - } else { - final LocalDate today = DateUtils.getBusinessLocalDate(); - this.calculateInterestUsing(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); - } - } - } - - public Map close(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); - - if (DateUtils.isBefore(closedDate, getActivationDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("cannot.be.a.future.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - final List savingsAccountTransactions = retrieveListOfTransactions(); - if (savingsAccountTransactions.size() > 0) { - final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); - if (accountTransaction.isAfter(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.last.transaction.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - if (getAccountBalance().doubleValue() != 0) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.not.zero"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); - this.status = SavingsAccountStatusType.CLOSED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = closedDate; - this.closedBy = currentUser; - - return actualChanges; - } - - protected void validateActivityNotBeforeClientOrGroupTransferDate(final SavingsEvent event, final LocalDate activityDate) { - if (this.client != null) { - final LocalDate clientOfficeJoiningDate = this.client.getOfficeJoiningDate(); - if (DateUtils.isBefore(activityDate, clientOfficeJoiningDate)) { - throw new SavingsActivityPriorToClientTransferException(event.toString(), clientOfficeJoiningDate); - } - } - } - - private void validateAttemptToUndoTransferRelatedTransactions(final SavingsAccountTransaction savingsAccountTransaction) { - if (savingsAccountTransaction.isTransferRelatedTransaction()) { - throw new SavingsTransferTransactionsCannotBeUndoneException(savingsAccountTransaction.getId()); - } - } - - protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { - - LocalDate lockedInUntilLocalDate = null; - final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); - switch (lockinPeriodFrequencyType) { - case INVALID: - break; - case DAYS: - lockedInUntilLocalDate = activationLocalDate.plusDays(this.lockinPeriodFrequency); - break; - case WEEKS: - lockedInUntilLocalDate = activationLocalDate.plusWeeks(this.lockinPeriodFrequency); - break; - case MONTHS: - lockedInUntilLocalDate = activationLocalDate.plusMonths(this.lockinPeriodFrequency); - break; - case YEARS: - lockedInUntilLocalDate = activationLocalDate.plusYears(this.lockinPeriodFrequency); - break; - case WHOLE_TERM: - LOG.error("TODO Implement calculateDateAccountIsLockedUntil for WHOLE_TERM"); - break; - } - - return lockedInUntilLocalDate; - } - - public Group group() { - return this.group; - } - - public boolean isWithdrawalFeeApplicableForTransfer() { - return this.withdrawalFeeApplicableForTransfer; - } - - public void activateAccountBasedOnBalance() { - if (getStatus().isClosed() && !this.summary.getAccountBalance(getCurrency()).isZero()) { - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - } - } - - public LocalDate getClosedOnDate() { - return this.closedOnDate; - } - - public AppUser getClosedBy() { - return this.closedBy; - } - - public SavingsAccountSummary getSummary() { - return this.summary; - } - - public List getTransactions() { - return this.transactions; - } - - public void addTransaction(final SavingsAccountTransaction transaction) { - this.transactions.add(transaction); - } - - public void addTransactionToExisting(final SavingsAccountTransaction transaction) { - this.savingsAccountTransactions.add(transaction); - } - - public void setStatus(final Integer status) { - this.status = status; - } - - private Set associateChargesWithThisSavingsAccount(final Set savingsAccountCharges) { - for (final SavingsAccountCharge savingsAccountCharge : savingsAccountCharges) { - savingsAccountCharge.update(this); - } - return savingsAccountCharges; - } - - public boolean update(final Set newSavingsAccountCharges) { - if (newSavingsAccountCharges == null) { - return false; - } - - if (this.charges == null) { - this.charges = new HashSet<>(); - } - this.charges.clear(); - this.charges.addAll(associateChargesWithThisSavingsAccount(newSavingsAccountCharges)); - return true; - } - - public boolean hasCurrencyCodeOf(final String matchingCurrencyCode) { - if (this.currency == null) { - return false; - } - return this.currency.getCode().equalsIgnoreCase(matchingCurrencyCode); - } - - public void removeCharge(final SavingsAccountCharge charge) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isActive() || isApproved()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.charges.remove(charge); - } - - public void waiveCharge(final Long savingsAccountChargeId, final boolean backdatedTxnsAllowedTill) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final SavingsAccountCharge savingsAccountCharge = getCharge(savingsAccountChargeId); - - if (savingsAccountCharge.isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isWithdrawalFee()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.waiver.of.withdrawal.fee.not.supported"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // validate charge is not already paid or waived - if (savingsAccountCharge.isWaived()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } else if (savingsAccountCharge.isPaid()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // waive charge - final Money amountWaived = savingsAccountCharge.waive(getCurrency()); - handleWaiverChargeTransactions(savingsAccountCharge, amountWaived, backdatedTxnsAllowedTill); - } - - public void addCharge(final DateTimeFormatter formatter, final SavingsAccountCharge savingsAccountCharge, - final Charge chargeDefinition) { - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (!hasCurrencyCodeOf(chargeDefinition.getCurrencyCode())) { - baseDataValidator.reset() - .failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.currency.and.charge.currency.not.same"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final LocalDate chargeDueDate = savingsAccountCharge.getDueDate(); - - if (savingsAccountCharge.isOnSpecifiedDueDate()) { - if (DateUtils.isBefore(chargeDueDate, getActivationDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("before.activationDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } else if (DateUtils.isBefore(chargeDueDate, getSubmittedOnDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getSubmittedOnDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("before.submittedOnDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isSavingsActivation() && !(isSubmittedAndPendingApproval() || (isApproved() && isNotActive()))) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.valid.account.status.cannot.add.activation.time.charge"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - // Only one annual fee is supported per account - if (savingsAccountCharge.isAnnualFee()) { - if (this.isAnnualFeeExists()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("multiple.annual.fee.per.account.not.supported"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - } - - if (savingsAccountCharge.isAnnualFee() || savingsAccountCharge.isMonthlyFee() || savingsAccountCharge.isWeeklyFee()) { - // update due date - if (isActive()) { - savingsAccountCharge.updateToNextDueDateFrom(getActivationDate()); - } else if (isApproved()) { - savingsAccountCharge.updateToNextDueDateFrom(getApprovedOnDate()); - } - } - - // activation charge and withdrawal charges not required this validation - if (savingsAccountCharge.isOnSpecifiedDueDate()) { - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLY_CHARGE, chargeDueDate); - } - - // add new charge to savings account - this.charges.add(savingsAccountCharge); - - } - - private boolean isAnnualFeeExists() { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isAnnualFee()) { - return true; - } - } - return false; - } - - public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final BigDecimal amountPaid, - final LocalDate transactionDate, final DateTimeFormatter formatter, final boolean backdatedTxnsAllowedTill, - final String refNo) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (DateUtils.isBefore(transactionDate, getActivationDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.before.activationDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDate)) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(transactionDate.format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.is.futureDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (savingsAccountCharge.isSavingsActivation()) { - baseDataValidator.reset() - .failWithCodeNoParameterAddedToErrorCode("transaction.not.valid.cannot.pay.activation.time.charge.is.automated"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (savingsAccountCharge.isAnnualFee()) { - final LocalDate annualFeeDueDate = savingsAccountCharge.getDueDate(); - if (annualFeeDueDate == null) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.annualfee.settings"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDate, annualFeeDueDate)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.before.dueDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - LocalDate currentAnnualFeeNextDueDate = findLatestAnnualFeeTransactionDueDate(); - if (DateUtils.isEqual(currentAnnualFeeNextDueDate, transactionDate)) { - baseDataValidator.reset().parameter("dueDate").value(transactionDate.format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.exists.on.date"); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // validate charge is not already paid or waived - if (savingsAccountCharge.isWaived()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } else if (savingsAccountCharge.isPaid()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Money chargePaid = Money.of(currency, amountPaid); - if (!savingsAccountCharge.getAmountOutstanding(getCurrency()).isGreaterThanOrEqualTo(chargePaid)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.charge.amount.paid.in.access"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - return this.payCharge(savingsAccountCharge, chargePaid, transactionDate, backdatedTxnsAllowedTill, refNo); - } - - public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final Money amountPaid, - final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, String refNo) { - savingsAccountCharge.pay(getCurrency(), amountPaid); - return handlePayChargeTransactions(savingsAccountCharge, amountPaid, transactionDate, backdatedTxnsAllowedTill, refNo); - } - - private SavingsAccountTransaction handlePayChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, - final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, final String refNo) { - SavingsAccountTransaction chargeTransaction; - - if (savingsAccountCharge.isWithdrawalFee()) { - chargeTransaction = SavingsAccountTransaction.withdrawalFee(this, office(), transactionDate, transactionAmount, refNo); - } else if (savingsAccountCharge.isAnnualFee()) { - chargeTransaction = SavingsAccountTransaction.annualFee(this, office(), transactionDate, transactionAmount); - } else { - chargeTransaction = SavingsAccountTransaction.charge(this, office(), transactionDate, transactionAmount); - } - - handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); - return chargeTransaction; - } - - private void handleWaiverChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, - boolean backdatedTxnsAllowedTill) { - final SavingsAccountTransaction chargeTransaction = SavingsAccountTransaction.waiver(this, office(), - DateUtils.getBusinessLocalDate(), transactionAmount); - handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); - } - - private void handleChargeTransactions(final SavingsAccountCharge savingsAccountCharge, final SavingsAccountTransaction transaction, - final boolean backdatedTxnsAllowedTill) { - // Provide a link between transaction and savings charge for which - // amount is waived. - final SavingsAccountChargePaidBy chargePaidBy = SavingsAccountChargePaidBy.instance(transaction, savingsAccountCharge, - transaction.getAmount(this.currency).getAmount()); - transaction.getSavingsAccountChargesPaid().add(chargePaidBy); - if (backdatedTxnsAllowedTill) { - this.savingsAccountTransactions.add(transaction); - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } else { - this.transactions.add(transaction); - } - } - - private SavingsAccountCharge getCharge(final Long savingsAccountChargeId) { - SavingsAccountCharge charge = null; - for (final SavingsAccountCharge existingCharge : this.charges) { - if (existingCharge.getId().equals(savingsAccountChargeId)) { - charge = existingCharge; - break; - } - } - - if (charge == null) { - throw new SavingsAccountChargeNotFoundException(savingsAccountChargeId, getId()); - } - - return charge; - } - - public Set charges() { - return (this.charges == null) ? new HashSet() : this.charges; - } - - public void validateAccountValuesWithProduct() { - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (this.overdraftLimit != null && this.product.overdraftLimit() != null - && this.overdraftLimit.compareTo(this.product.overdraftLimit()) > 0) { - baseDataValidator.reset().parameter(SavingsApiConstants.overdraftLimitParamName).value(this.overdraftLimit) - .failWithCode("cannot.exceed.product.value"); - } - - validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public boolean allowOverdraft() { - return this.allowOverdraft; - } - - public LocalDate accountSubmittedOrActivationDate() { - return getActivationDate() == null ? getSubmittedOnDate() : getActivationDate(); - } - - protected boolean isTransferInterestToOtherAccount() { - return false; - } - - public boolean accountSubmittedAndActivationOnSameDate() { - return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate()); - - } - - public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidatorBuilder baseDataValidator) { - Map> postingtoCompoundMap = new HashMap<>(); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.DAILY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.MONTHLY, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.QUATERLY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, - SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.BIANNUAL, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, - SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNUAL, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, - SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, - SavingsCompoundingInterestPeriodType.ANNUAL)); - - SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType - .fromInt(interestPostingPeriodType); - SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(interestCompoundingPeriodType); - - if (postingtoCompoundMap.get(savingsPostingInterestPeriodType) == null) { - baseDataValidator.failWithCodeNoParameterAddedToErrorCode("posting.period.type.is.less.than.compound.period.type", - savingsPostingInterestPeriodType.name(), savingsCompoundingInterestPeriodType.name()); - - } - } - - public AppUser getSubmittedBy() { - return this.submittedBy; - } - - public AppUser getApprovedBy() { - return this.approvedBy; - } - - public boolean allowDeposit() { - return true; - } - - public boolean allowWithdrawal() { - return true; - } - - public boolean allowModify() { - return true; - } - - public boolean isTransactionsAllowed() { - return isActive(); - } - - public boolean isTransactionAllowed(SavingsAccountTransactionType transactionType, LocalDate transactionDate) { - if (!isTransactionsAllowed()) { - return false; - } - - Client client = getClient(); - if (client != null && !client.isActive()) { - return false; - } - Group group = group(); - if (group != null && !group.isActive()) { - return false; - } - - if (transactionDate == null) { - return true; - } - if (DateUtils.isDateInTheFuture(transactionDate) || DateUtils.isBefore(transactionDate, getActivationDate())) { - return false; - } - if (transactionType.isCredit()) { - return true; - } - return !isAccountLocked(transactionDate); - } - - public BigDecimal minBalanceForInterestCalculation() { - return this.minBalanceForInterestCalculation; - } - - public void inactivateCharge(SavingsAccountCharge savingsAccountCharge, LocalDate inactivationOnDate) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - savingsAccountCharge.inactiavateCharge(inactivationOnDate); - } - - public SavingsAccountCharge getUpdatedChargeDetails(SavingsAccountCharge savingsAccountCharge) { - for (final SavingsAccountCharge charge : this.charges) { - if (charge.equals(savingsAccountCharge)) { - savingsAccountCharge = charge; - break; - } - } - return savingsAccountCharge; - } - - public String getAccountNumber() { - return this.accountNumber; - } - - private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { - // Minimum required balance is not enforced -- withdrawals (and every other balance check - // that goes through this) must never be blocked by a configured minimum balance. Overdraft - // headroom is a separate concept and still applies. - Money minReqBalance = Money.zero(currency); - if (this.allowOverdraft) { - minReqBalance = minReqBalance.minus(this.overdraftLimit); - } - return minReqBalance; - } - - public BigDecimal getOnHoldFunds() { - return this.onHoldFunds == null ? BigDecimal.ZERO : this.onHoldFunds; - } - - public void holdFunds(BigDecimal onHoldFunds) { - this.onHoldFunds = getOnHoldFunds().add(onHoldFunds); - } - - public void releaseFunds(BigDecimal onHoldFunds) { - this.onHoldFunds = getOnHoldFunds().subtract(onHoldFunds); - } - - public BigDecimal getWithdrawableBalance() { - return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) - .subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing()); - } - - public BigDecimal getWithdrawableBalanceWithLien() { - return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) - .subtract(this.getSavingsHoldAmount()); - } - - public BigDecimal getWithdrawableBalanceWithoutMinimumBalance() { - return getAccountBalance().subtract(this.getOnHoldFunds()).subtract(this.getSavingsHoldAmount()); - } - - public TaxGroup getTaxGroup() { - return this.taxGroup; - } - - public boolean withHoldTax() { - return this.withHoldTax; - } - - public void setWithHoldTax(boolean withHoldTax) { - this.withHoldTax = withHoldTax; - } - - protected boolean applyWithholdTaxForDepositAccounts(final LocalDate interestPostingUpToDate, boolean recalucateDailyBalance, - final boolean backdatedTxnsAllowedTill) { - final List withholdTransactions = findWithHoldTransactions(); - SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingUpToDate, withholdTransactions); - final BigDecimal totalInterestPosted = this.savingsAccountTransactionSummaryWrapper.calculateTotalInterestPosted(this.currency, - this.transactions); - if (withholdTransaction == null && this.withHoldTax()) { - boolean isWithholdTaxAdded = createWithHoldTransaction(totalInterestPosted, interestPostingUpToDate, backdatedTxnsAllowedTill); - recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; - } else { - boolean isWithholdTaxAdded = updateWithHoldTransaction(totalInterestPosted, withholdTransaction); - recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; - } - - return recalucateDailyBalance; - } - - public void setSubStatusInactive(final boolean backdatedTxnsAllowedTill) { - this.sub_status = SavingsAccountSubStatusEnum.INACTIVE.getValue(); - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isSavingsNoActivity() && charge.isActive()) { - charge.updateWithdralFeeAmount(this.getAccountBalance()); - UUID refNo = UUID.randomUUID(); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo.toString()); - } - } - boolean postReversals = false; - recalculateDailyBalances(Money.zero(this.currency), transactionDate, backdatedTxnsAllowedTill, postReversals); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void recalculateBalancesAfterFacilityCharges(final LocalDate transactionDate) { - recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, false); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void setSubStatusDormant() { - this.sub_status = SavingsAccountSubStatusEnum.DORMANT.getValue(); - } - - public void escheat(AppUser appUser) { - this.status = SavingsAccountStatusType.CLOSED.getValue(); - this.sub_status = SavingsAccountSubStatusEnum.ESCHEAT.getValue(); - this.closedOnDate = DateUtils.getBusinessLocalDate(); - this.closedBy = appUser; - boolean postInterestAsOnDate = false; - boolean postReversals = false; - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - if (this.getSummary().getAccountBalance(this.getCurrency()).isGreaterThanZero()) { - SavingsAccountTransaction transaction = SavingsAccountTransaction.escheat(this, transactionDate, postInterestAsOnDate); - this.transactions.add(transaction); - } - recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, postReversals); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void loadLazyCollections() { - transactions.size(); - charges.size(); - savingsOfficerHistory.size(); - if (group != null) { - Office dummyOffice = group.getOffice(); - } // Ensure lazy loading of group if set - } - - public void updateSavingsAccountSummary(final List transactions) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, transactions); - } - - public void updateReason(final String reasonForBlock) { - this.reasonForBlock = reasonForBlock; - } - - public Map block() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblock() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.blocked.state"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public Map blockCredits(Integer currentSubstatus) { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockCreditsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblockCredits() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockCreditsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!(SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus) - || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode("credits.are.not.blocked"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public Map blockDebits(Integer currentSubstatus) { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockDebitsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblockDebits() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockDebitsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus) - || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .failWithCodeNoParameterAddedToErrorCode("debits.are.not.blocked"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public SavingsAccountStatusType getStatus() { - return SavingsAccountStatusType.fromInt(status); - } - - public Integer getSubStatus() { - return this.sub_status; - } - - public void validateForAccountBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - throw new SavingsAccountBlockedException(this.getId()); - } - } - - public void validateForDebitBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus)) { - throw new SavingsAccountDebitsBlockedException(this.getId()); - } - } - - public void validateForCreditBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus)) { - throw new SavingsAccountCreditsBlockedException(this.getId()); - } - } - - public LocalDate retrieveLastTransactionDate() { - final List transactionsSortedByDate = retrieveListOfTransactions(); - SavingsAccountTransaction lastTransaction = null; - if (transactionsSortedByDate.size() > 0) { - lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); - } - LocalDate lastransactionDate = null; - if (lastTransaction != null) { - lastransactionDate = lastTransaction.getTransactionDate(); - } - return lastransactionDate; - } - - public LocalDate retrieveLastTransactionDateWithPivotConfig() { - final List transactionsSortedByDate = retrieveSortedTransactions(); - SavingsAccountTransaction lastTransaction = null; - if (transactionsSortedByDate.size() > 0) { - lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); - } - LocalDate lastransactionDate = null; - if (lastTransaction != null) { - lastransactionDate = lastTransaction.getTransactionDate(); - } - return lastransactionDate; - } - - /** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a - * {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather - * than a decision someone made about this specific account. */ - public static final String HOLD_CATEGORY_CASH = "CASH"; - public static final String HOLD_CATEGORY_CLEARING = "CLEARING"; - - public BigDecimal getSavingsHoldAmount() { - return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount; - } - - public BigDecimal getSavingsHoldAmountClearing() { - return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing; - } - - /** Defaults to the manual-lien bucket -- every call site written before the two-category split - * keeps behaving exactly as it did before. */ - public void holdAmount(BigDecimal amount) { - holdAmount(amount, HOLD_CATEGORY_CASH); - } - - public void holdAmount(BigDecimal amount, String category) { - if (HOLD_CATEGORY_CLEARING.equals(category)) { - this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount); - } else { - this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); - } - } - - public void releaseOnHoldAmount(BigDecimal amount) { - releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH); - } - - public void releaseOnHoldAmount(BigDecimal amount, String category) { - if (HOLD_CATEGORY_CLEARING.equals(category)) { - this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount); - } else { - this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); - } - } - - public AccountType getAccountType() { - return AccountType.fromInt(accountType); - } - - public Integer getAccountTypes() { - return accountType; - } - - public void setAccountType(Integer accountType) { - this.accountType = accountType; - } - - private boolean isOverdraft() { - return allowOverdraft; - } - - public Long getGroupId() { - return this.groupId(); - } - - public Integer getInterestCalculationDaysInYearType() { - return this.interestCalculationDaysInYearType; - } - - public BigDecimal getMinRequiredOpeningBalance() { - return this.minRequiredOpeningBalance; - } - - public Integer getLockinPeriodFrequency() { - return this.lockinPeriodFrequency; - } - - public Integer getLockinPeriodFrequencyType() { - return this.lockinPeriodFrequencyType; - } - - public boolean isWithdrawalFeeForTransfer() { - return this.withdrawalFeeApplicableForTransfer; - } - - public boolean isAllowOverdraft() { - return this.allowOverdraft; - } - - public BigDecimal getOverdraftLimit() { - return this.overdraftLimit; - } - - public BigDecimal getMinOverdraftForInterestCalculation() { - return this.minOverdraftForInterestCalculation; - } - - public BigDecimal getMinRequiredBalance() { - return this.minRequiredBalance; - } - - public boolean isEnforceMinRequiredBalance() { - return this.enforceMinRequiredBalance; - } - - public BigDecimal getMaxAllowedLienLimit() { - return this.maxAllowedLienLimit; - } - - public boolean isLienAllowed() { - return this.lienAllowed; - } - - public BigDecimal getMinBalanceForInterestCalculation() { - return this.minBalanceForInterestCalculation; - } - - public int getVersion() { - return this.version; - } - - public boolean isWithHoldTax() { - return this.withHoldTax; - } - - public void setAccruedTillDate(LocalDate accruedTillDate) { - this.accruedTillDate = accruedTillDate; - } - - public LocalDate getLastClosedBusinessDate() { - return this.lastClosedBusinessDate; - } - - public void setLastClosedBusinessDate(LocalDate lastClosedBusinessDate) { - this.lastClosedBusinessDate = lastClosedBusinessDate; - } - - public List toSavingsAccountTransactionDetailsForPostingPeriodList( - List transactions) { - return transactions.stream() - .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) - .toList(); - } - - public void accrualsForSavingsReverse(SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill) { - List accountTransactionsSorted = null; - - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { - boolean typeTransaccionValidation = transaction.getTransactionType() == SavingsAccountTransactionType.ACCRUAL; - if (typeTransaccionValidation && (transaction.getDateOf().isAfter(transactionDTO.getTransactionDate()) - || transaction.getDateOf().isEqual(transactionDTO.getTransactionDate()))) { - transaction.reverse(); - } - } - } - - public List toSavingsAccountTransactionDetailsForPostingPeriodList() { - return retreiveOrderedNonInterestPostingTransactions().stream() - .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) - .toList(); - } - - @Override - public DepositAccountType depositAccountType() { - return DepositAccountType.fromInt(100); - } - - // ─── CBS extended fields ─────────────────────────────────────────────────── - - @Column(name = "cbs_account_type", length = 100) - private String cbsAccountType; - - @Column(name = "customer_segment", length = 100) - private String customerSegment; - - @Column(name = "account_status", length = 50) - private String accountStatus; - - @Column(name = "mode_of_operation", length = 50) - private String modeOfOperation; - - @Column(name = "special_category", length = 100) - private String specialCategory; - - @Column(name = "weaker_section", length = 100) - private String weakerSection; - - @Column(name = "guardian_name", length = 200) - private String guardianName; - - @Column(name = "guardian_cif_number", length = 50) - private String guardianCifNumber; - - @Column(name = "guardian_relationship", length = 100) - private String guardianRelationship; - - @Column(name = "minor_date_of_birth") - private LocalDate minorDateOfBirth; - - @Column(name = "guardian_client_id") - private Long guardianClientId; - - @Column(name = "introducer_client_id") - private Long introducerClientId; - - @Column(name = "introducer_name", length = 200) - private String introducerName; - - @Column(name = "introducer_cif_number", length = 50) - private String introducerCifNumber; - - @Column(name = "nominee_data", columnDefinition = "TEXT") - private String nomineeData; - - @Column(name = "linked_account_id") - private Long linkedAccountId; - - @Column(name = "linked_account_type", length = 20) - private String linkedAccountType; - - @Column(name = "linked_account_no", length = 50) - private String linkedAccountNo; - - @Column(name = "introducer_required", nullable = false, columnDefinition = "boolean default false") - private Boolean introducerRequired = false; - - @Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false") - private Boolean shareLinkingEnabled = false; - - /** - * Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain - * class (it lives in fineract-provider, which depends on fineract-savings, not the other way - * round). The write side resolves and validates this id against m_share_account directly; this - * column just carries it back to the share account's own {@code savings_account_id}, mirroring - * the link from both ends. - */ - @Column(name = "share_account_id") - private Long shareAccountId; - - @Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false") - private Boolean nominationFacility = false; - - @Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false") - private Boolean autoDebitFacility = false; - - @Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false") - private boolean isSeniorCitizen = false; - - @Column(name = "senior_citizen_interest_rate", scale = 6, precision = 19, nullable = true) - private BigDecimal seniorCitizenInterestRate; - - @Column(name = "terminate_true", nullable = false, columnDefinition = "boolean default false") - private boolean terminateTrue = false; - - public boolean isTerminateTrue() { return terminateTrue; } - public void setTerminateTrue(boolean terminateTrue) { this.terminateTrue = terminateTrue; } - - public Map terminate() { - final Map actualChanges = new LinkedHashMap<>(); - this.terminateTrue = true; - this.status = SavingsAccountStatusType.TERMINATED.getValue(); - actualChanges.put("terminateTrue", true); - actualChanges.put("status", SavingsAccountStatusType.TERMINATED.getValue()); - return actualChanges; - } - - - @jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private SavingsAccountFacilityConfig facilityConfig; - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private List jointHolders = new ArrayList<>(); - - public String getCbsAccountType() { return cbsAccountType; } - public void setCbsAccountType(String v) { this.cbsAccountType = v; } - - public String getCustomerSegment() { return customerSegment; } - public void setCustomerSegment(String v) { this.customerSegment = v; } - - public String getAccountStatus() { return accountStatus; } - public void setAccountStatus(String v) { this.accountStatus = v; } - - public String getModeOfOperation() { return modeOfOperation; } - public void setModeOfOperation(String v) { this.modeOfOperation = v; } - - public String getSpecialCategory() { return specialCategory; } - public void setSpecialCategory(String v) { this.specialCategory = v; } - - public String getWeakerSection() { return weakerSection; } - public void setWeakerSection(String v) { this.weakerSection = v; } - - public String getGuardianName() { return guardianName; } - public void setGuardianName(String v) { this.guardianName = v; } - - public String getGuardianCifNumber() { return guardianCifNumber; } - public void setGuardianCifNumber(String v) { this.guardianCifNumber = v; } - - public String getGuardianRelationship() { return guardianRelationship; } - public void setGuardianRelationship(String v) { this.guardianRelationship = v; } - - public LocalDate getMinorDateOfBirth() { return minorDateOfBirth; } - public void setMinorDateOfBirth(LocalDate v) { this.minorDateOfBirth = v; } - - public Long getGuardianClientId() { return guardianClientId; } - public void setGuardianClientId(Long v) { this.guardianClientId = v; } - - public Long getIntroducerClientId() { return introducerClientId; } - public void setIntroducerClientId(Long v) { this.introducerClientId = v; } - - public String getIntroducerName() { return introducerName; } - public void setIntroducerName(String v) { this.introducerName = v; } - - public String getIntroducerCifNumber() { return introducerCifNumber; } - public void setIntroducerCifNumber(String v) { this.introducerCifNumber = v; } - - public String getNomineeData() { return nomineeData; } - public void setNomineeData(String v) { this.nomineeData = v; } - - public Long getLinkedAccountId() { return linkedAccountId; } - public void setLinkedAccountId(Long v) { this.linkedAccountId = v; } - - public String getLinkedAccountType() { return linkedAccountType; } - public void setLinkedAccountType(String v) { this.linkedAccountType = v; } - - public String getLinkedAccountNo() { return linkedAccountNo; } - public void setLinkedAccountNo(String v) { this.linkedAccountNo = v; } - - public SavingsAccountFacilityConfig getFacilityConfig() { return facilityConfig; } - public void setFacilityConfig(SavingsAccountFacilityConfig v) { this.facilityConfig = v; } - - public List getJointHolders() { return jointHolders; } - public void setJointHolders(List v) { this.jointHolders = v; } - - public Boolean getNominationFacility() { return nominationFacility; } - public void setNominationFacility(Boolean v) { this.nominationFacility = v; } - - public Boolean getAutoDebitFacility() { return autoDebitFacility; } - public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } - - public Boolean getIntroducerRequired() { return introducerRequired; } - public void setIntroducerRequired(Boolean v) { this.introducerRequired = v; } - - public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; } - public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; } - - public Long getShareAccountId() { return shareAccountId; } - public void setShareAccountId(Long v) { this.shareAccountId = v; } - - public boolean isSeniorCitizen() { return isSeniorCitizen; } - public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; } - - public BigDecimal getSeniorCitizenInterestRate() { return seniorCitizenInterestRate; } - public void setSeniorCitizenInterestRate(BigDecimal v) { this.seniorCitizenInterestRate = v; } - - public void updateNominalAnnualInterestRate(BigDecimal rate) { - if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { - this.nominalAnnualInterestRate = rate; - } - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.domain; + +import static java.time.temporal.ChronoUnit.DAYS; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lienAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.localeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.maxAllowedLienLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minOverdraftForInterestCalculationParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequiredBalanceParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withdrawalFeeForTransfersParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; + +import com.google.gson.JsonArray; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator; +import org.apache.fineract.interoperation.domain.InteropIdentifier; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.portfolio.accountdetails.domain.AccountType; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.exception.SavingsAccountChargeNotFoundException; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.group.domain.Group; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; +import org.apache.fineract.portfolio.savings.SavingsApiConstants; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; +import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountCreditsBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountDebitsBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; +import org.apache.fineract.portfolio.savings.exception.SavingsActivityPriorToClientTransferException; +import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentDateException; +import org.apache.fineract.portfolio.savings.exception.SavingsOfficerUnassignmentDateException; +import org.apache.fineract.portfolio.savings.exception.SavingsTransferTransactionsCannotBeUndoneException; +import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.apache.fineract.portfolio.tax.service.TaxUtils; +import org.apache.fineract.useradministration.domain.AppUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.CollectionUtils; + +@Entity +@Table(name = "m_savings_account", uniqueConstraints = { @UniqueConstraint(columnNames = { "account_no" }, name = "sa_account_no_UNIQUE"), + @UniqueConstraint(columnNames = { "external_id" }, name = "sa_external_id_UNIQUE") }) +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "deposit_type_enum", discriminatorType = DiscriminatorType.INTEGER) +@DiscriminatorValue("100") +@SuppressWarnings({ "MemberName" }) +public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom implements IDepositAccountType { + + private static final Logger LOG = LoggerFactory.getLogger(SavingsAccount.class); + + @Version + int version; + + @Column(name = "account_no", length = 20, unique = true, nullable = false) + protected String accountNumber; + + @Column(name = "external_id", nullable = true) + protected ExternalId externalId; + + @ManyToOne(optional = true) + @JoinColumn(name = "client_id", nullable = true) + protected Client client; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = true) + protected Group group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "gsim_id", nullable = true) + private GroupSavingsIndividualMonitoring gsim; + + @ManyToOne + @JoinColumn(name = "product_id", nullable = false) + protected SavingsProduct product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "field_officer_id", nullable = true) + protected Staff savingsOfficer; + + @Column(name = "status_enum", nullable = false) + protected Integer status; + + @Column(name = "sub_status_enum", nullable = false) + protected Integer sub_status = 0; + + @Column(name = "account_type_enum", nullable = false) + protected Integer accountType; + + @Column(name = "submittedon_date", nullable = true) + protected LocalDate submittedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "submittedon_userid", nullable = true) + protected AppUser submittedBy; + + @Column(name = "rejectedon_date") + protected LocalDate rejectedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "rejectedon_userid", nullable = true) + protected AppUser rejectedBy; + + @Column(name = "withdrawnon_date") + protected LocalDate withdrawnOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "withdrawnon_userid", nullable = true) + protected AppUser withdrawnBy; + + @Column(name = "approvedon_date") + protected LocalDate approvedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "approvedon_userid", nullable = true) + protected AppUser approvedBy; + + @Column(name = "activatedon_date", nullable = true) + protected LocalDate activatedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "activatedon_userid", nullable = true) + protected AppUser activatedBy; + + @Column(name = "closedon_date") + protected LocalDate closedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "closedon_userid", nullable = true) + protected AppUser closedBy; + + @Column(name = "reason_for_block", nullable = true) + protected String reasonForBlock; + + @Embedded + protected MonetaryCurrency currency; + + @Column(name = "nominal_annual_interest_rate", scale = 6, precision = 19, nullable = false) + protected BigDecimal nominalAnnualInterestRate; + + /** + * The interest period is the span of time at the end of which savings in a client's account earn interest. + * + * A value from the {@link SavingsCompoundingInterestPeriodType} enumeration. + */ + @Column(name = "interest_compounding_period_enum", nullable = false) + protected Integer interestCompoundingPeriodType; + + /** + * A value from the {@link SavingsPostingInterestPeriodType} enumeration. + */ + @Column(name = "interest_posting_period_enum", nullable = false) + protected Integer interestPostingPeriodType; + + /** + * A value from the {@link SavingsInterestCalculationType} enumeration. + */ + @Column(name = "interest_calculation_type_enum", nullable = false) + protected Integer interestCalculationType; + + /** + * A value from the {@link SavingsInterestCalculationDaysInYearType} enumeration. + */ + @Column(name = "interest_calculation_days_in_year_type_enum", nullable = false) + protected Integer interestCalculationDaysInYearType; + + @Column(name = "min_required_opening_balance", scale = 6, precision = 19, nullable = true) + protected BigDecimal minRequiredOpeningBalance; + + @Column(name = "lockin_period_frequency", nullable = true) + protected Integer lockinPeriodFrequency; + + @Column(name = "lockin_period_frequency_enum", nullable = true) + protected Integer lockinPeriodFrequencyType; + + /** + * When account becomes active this field is derived if lockinPeriodFrequency and + * lockinPeriodFrequencyType details are present. + */ + @Column(name = "lockedin_until_date_derived", nullable = true) + protected LocalDate lockedInUntilDate; + + @Column(name = "withdrawal_fee_for_transfer", nullable = true) + protected boolean withdrawalFeeApplicableForTransfer; + + @Column(name = "allow_overdraft") + private boolean allowOverdraft; + + @Column(name = "overdraft_limit", scale = 6, precision = 19, nullable = true) + private BigDecimal overdraftLimit; + + @Column(name = "nominal_annual_interest_rate_overdraft", scale = 6, precision = 19, nullable = true) + protected BigDecimal nominalAnnualInterestRateOverdraft; + + @Column(name = "min_overdraft_for_interest_calculation", scale = 6, precision = 19, nullable = true) + private BigDecimal minOverdraftForInterestCalculation; + + @Column(name = "enforce_min_required_balance") + private boolean enforceMinRequiredBalance; + + @Column(name = "min_required_balance", scale = 6, precision = 19, nullable = true) + private BigDecimal minRequiredBalance; + + @Column(name = "is_lien_allowed", nullable = false) + private boolean lienAllowed; + + @Column(name = "max_allowed_lien_limit", scale = 6, precision = 19, nullable = true) + private BigDecimal maxAllowedLienLimit; + + @Column(name = "on_hold_funds_derived", scale = 6, precision = 19, nullable = true) + private BigDecimal onHoldFunds; + + @Column(name = "start_interest_calculation_date") + protected LocalDate startInterestCalculationDate; + + @Embedded + protected SavingsAccountSummary summary; + + @OrderBy(value = "dateOf, createdDate, id") + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + protected List transactions = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + protected Set charges = new HashSet<>(); + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private Set savingsOfficerHistory = new HashSet<>(); + + @Transient + protected boolean accountNumberRequiresAutoGeneration = false; + @Transient + protected SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; + @Transient + protected SavingsHelper savingsHelper; + @Transient + protected List savingsAccountTransactions = new ArrayList<>(); + + @Column(name = "deposit_type_enum", insertable = false, updatable = false) + private Integer depositType; + + @Column(name = "min_balance_for_interest_calculation", scale = 6, precision = 19, nullable = true) + private BigDecimal minBalanceForInterestCalculation; + + @Column(name = "withhold_tax", nullable = false) + protected boolean withHoldTax; + + @ManyToOne + @JoinColumn(name = "tax_group_id") + private TaxGroup taxGroup; + + @Column(name = "accrued_till_date") + private LocalDate accruedTillDate; + + @Column(name = "last_closed_business_date") + private LocalDate lastClosedBusinessDate; + + @Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true) + private BigDecimal savingsOnHoldAmount; + + /** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet + * realized, or a presented cheque not yet settled) -- kept as its own running total, separate from + * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures + * rather than one bucket doing double duty. */ + /* + * Initialised to zero rather than left null: the column is NOT NULL with a default of 0, and a + * default only applies when the column is omitted from the INSERT. JPA writes every mapped + * field, so a null here reaches the database as an explicit null and is rejected — which is what + * made every new savings account fail with a data-integrity error. + */ + @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = false) + private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) + protected List identifiers = new ArrayList<>(); + + public transient ConfigurationDomainService configurationDomainService; + + protected SavingsAccount() { + // + } + + public static SavingsAccount createNewApplicationForSubmittal(final Client client, final Group group, final SavingsProduct product, + final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, + final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, + final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, + final boolean withHoldTax) { + + final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; + return new SavingsAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, + submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, enforceMinRequiredBalance, + minRequiredBalance, maxAllowedLienLimit, lienAllowed, nominalAnnualInterestRateOverdraft, + minOverdraftForInterestCalculation, withHoldTax); + } + + protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, + final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, boolean withHoldTax) { + this(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, + nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, false, null, null, false, null, + null, withHoldTax); + } + + protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff savingsOfficer, + final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, + final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, + final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, boolean withHoldTax) { + this.client = client; + this.group = group; + this.product = product; + this.savingsOfficer = savingsOfficer; + if (StringUtils.isBlank(accountNo)) { + this.accountNumber = new RandomPasswordGenerator(19).generate(); + this.accountNumberRequiresAutoGeneration = true; + } else { + this.accountNumber = accountNo; + } + + this.currency = product.currency(); + this.externalId = externalId; + this.status = status.getValue(); + this.accountType = accountType.getValue(); + this.submittedOnDate = submittedOnDate; + this.submittedBy = submittedBy; + this.nominalAnnualInterestRate = nominalAnnualInterestRate; + this.interestCompoundingPeriodType = interestCompoundingPeriodType.getValue(); + this.interestPostingPeriodType = interestPostingPeriodType.getValue(); + this.interestCalculationType = interestCalculationType.getValue(); + this.interestCalculationDaysInYearType = interestCalculationDaysInYearType.getValue(); + this.minRequiredOpeningBalance = minRequiredOpeningBalance; + this.lockinPeriodFrequency = lockinPeriodFrequency; + if (lockinPeriodFrequencyType != null) { + this.lockinPeriodFrequencyType = lockinPeriodFrequencyType.getValue(); + } + this.withdrawalFeeApplicableForTransfer = withdrawalFeeApplicableForTransfer; + + if (!CollectionUtils.isEmpty(savingsAccountCharges)) { + this.charges = associateChargesWithThisSavingsAccount(savingsAccountCharges); + } + + this.summary = new SavingsAccountSummary(); + this.allowOverdraft = allowOverdraft; + this.overdraftLimit = overdraftLimit; + this.nominalAnnualInterestRateOverdraft = nominalAnnualInterestRateOverdraft; + this.minOverdraftForInterestCalculation = minOverdraftForInterestCalculation; + esnureOverdraftLimitsSetForOverdraftAccounts(); + + this.enforceMinRequiredBalance = enforceMinRequiredBalance; + this.minRequiredBalance = minRequiredBalance; + this.lienAllowed = lienAllowed; + this.maxAllowedLienLimit = maxAllowedLienLimit; + this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); + // this.savingsOfficerHistory = null; + this.withHoldTax = withHoldTax; + this.taxGroup = product.getTaxGroup(); + } + + /** + * Used after fetching/hydrating a {@link SavingsAccount} object to inject helper services/components used for + * update summary details after events/transactions on a {@link SavingsAccount}. + */ + public void setHelpers(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, + final SavingsHelper savingsHelper) { + this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; + this.savingsHelper = savingsHelper; + } + + public void setSavingsAccountTransactions(final List savingsAccountTransactions) { + this.savingsAccountTransactions.addAll(savingsAccountTransactions); + } + + public List getSavingsAccountTransactionsWithPivotConfig() { + return this.savingsAccountTransactions; + } + + public ExternalId getExternalId() { + return externalId; + } + + public boolean isNotActive() { + return !isActive(); + } + + public boolean isActive() { + return SavingsAccountStatusType.fromInt(this.status).isActive(); + } + + public boolean isNotSubmittedAndPendingApproval() { + return !isSubmittedAndPendingApproval(); + } + + public boolean isSubmittedAndPendingApproval() { + return SavingsAccountStatusType.fromInt(this.status).isSubmittedAndPendingApproval(); + } + + public boolean isApproved() { + return SavingsAccountStatusType.fromInt(this.status).isApproved(); + } + + public boolean isActivated() { + boolean isActive = false; + if (this.activatedOnDate != null) { + isActive = true; + } + return isActive; + } + + public boolean isClosed() { + return SavingsAccountStatusType.fromInt(this.status).isClosed(); + } + + public List getIdentifiers() { + return identifiers; + } + + /** + * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto + * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the + * correction, so plain savings and recurring deposit behaviour is unchanged. + */ + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return true; + } + + /** + * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an + * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation + * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit + * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called + * instead. + */ + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return true; + } + + /** + * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest + * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running + * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, + * regardless of whether any posting correction was needed -- without re-running the interest + * posting/correction loop that recalculation drives. + */ + public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, + final boolean postReversals) { + Money openingAccountBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + + public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, + postReversals); + if (postingPeriods.isEmpty()) { + return; + } + + Money interestPostedToDate = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + interestPostedToDate = Money.of(this.currency, this.summary.getTotalInterestPosted()); + } + + boolean recalucateDailyBalanceDetails = false; + boolean applyWithHoldTax = isWithHoldTaxApplicableForInterestPosting(); + final List withholdTransactions = new ArrayList<>(); + + if (backdatedTxnsAllowedTill) { + withholdTransactions.addAll(findWithHoldSavingsTransactionsWithPivotConfig()); + } else { + withholdTransactions.addAll(findWithHoldTransactions()); + } + + // Reverse manual interest postings that no longer line up with any period this run computed, + // BEFORE the periods below are applied. + // + // A user posting ("post interest as on D") lands its transaction on D-1, because + // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in + // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 + // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the + // posting's date matches no period end the second time around: findInterestPostingTransactionFor + // below misses it, it is never reversed or corrected, and it survives alongside a new posting + // covering the very same days. The member is credited twice, and total_interest_posted_derived + // drifts above what was actually earned. + // + // A posting is only treated as orphaned, and only reversed, when BOTH hold: + // - its date is not any period's posting date, so the loop below will never find and correct it; + // - some period that WILL be posted in this run (posting date on or before + // interestPostingUpToDate) covers that date, so the days it credited are being credited again. + // The second condition is what keeps this from ever destroying interest. A run whose remaining + // period only matures in the future posts nothing for those days -- the recalculation that + // follows a withdrawal is exactly that case -- and reversing there would silently take earned + // interest back off the member instead of de-duplicating it. + // Automatic postings are left alone: they sit on natural period ends, which always recur. + final Set periodPostingDates = new HashSet<>(); + final List repostedIntervals = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + periodPostingDates.add(period.dateOfPostingTransaction()); + if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { + repostedIntervals.add(period.getPeriodInterval()); + } + } + final List postingsInScope = backdatedTxnsAllowedTill + ? getSavingsAccountTransactionsWithPivotConfig() + : getTransactions(); + for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { + final LocalDate transactionDate = transaction.getTransactionDate(); + if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() + || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { + continue; + } + boolean coveredByAPostedPeriod = false; + for (final LocalDateInterval interval : repostedIntervals) { + if (interval.contains(transactionDate)) { + coveredByAPostedPeriod = true; + break; + } + } + if (!coveredByAPostedPeriod) { + continue; + } + transaction.reverse(); + final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); + if (strandedWithholdTransaction != null) { + strandedWithholdTransaction.reverse(); + } + recalucateDailyBalanceDetails = true; + } + + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); + final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); + + if (!DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate)) { + interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); + + SavingsAccountTransaction postingTransaction = null; + if (backdatedTxnsAllowedTill) { + postingTransaction = findInterestPostingSavingsTransactionWithPivotConfig(interestPostingTransactionDate); + } else { + postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate); + } + if (postingTransaction == null) { + SavingsAccountTransaction newPostingTransaction; + if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { + + newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), interestPostingTransactionDate, + interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); + } else { + newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), interestPostingTransactionDate, + interestEarnedToBePostedForPeriod.negated(), interestPostingPeriod.isUserPosting()); + } + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(newPostingTransaction); + } else { + addTransaction(newPostingTransaction); + } + if (applyWithHoldTax) { + createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, + backdatedTxnsAllowedTill); + } + recalucateDailyBalanceDetails = true; + } else { + boolean correctionRequired = false; + if (postingTransaction.isInterestPostingAndNotReversed()) { + correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); + } else { + correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); + } + if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { + boolean applyWithHoldTaxForOldTransaction = false; + postingTransaction.reverse(); + SavingsAccountTransaction reversal = null; + if (postReversals) { + reversal = SavingsAccountTransaction.reversal(postingTransaction); + } + final SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingTransactionDate, + withholdTransactions); + if (withholdTransaction != null) { + withholdTransaction.reverse(); + applyWithHoldTaxForOldTransaction = true; + } + SavingsAccountTransaction newPostingTransaction; + if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { + newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), + interestPostingTransactionDate, interestEarnedToBePostedForPeriod, + interestPostingPeriod.isUserPosting()); + } else { + newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), + interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), + interestPostingPeriod.isUserPosting()); + } + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(newPostingTransaction); + if (reversal != null) { + addTransactionToExisting(reversal); + } + } else { + addTransaction(newPostingTransaction); + if (reversal != null) { + addTransaction(reversal); + } + } + if (applyWithHoldTaxForOldTransaction) { + createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, + backdatedTxnsAllowedTill); + } + recalucateDailyBalanceDetails = true; + } + } + } + } + + if (recalucateDailyBalanceDetails) { + // no openingBalance concept supported yet but probably will to + // allow + // for migrations. + Money openingAccountBalance = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + + // update existing transactions so derived balance fields are + // correct. + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + } + + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + + protected List findWithHoldTransactions() { + final List withholdTransactions = new ArrayList<>(); + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isWithHoldTaxAndNotReversed()) { + withholdTransactions.add(transaction); + } + } + return withholdTransactions; + } + + protected List findWithHoldSavingsTransactionsWithPivotConfig() { + final List withholdTransactions = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isWithHoldTaxAndNotReversed() && !transaction.isReversalTransaction()) { + withholdTransactions.add(transaction); + } + } + return withholdTransactions; + } + + private boolean isWithHoldTaxApplicableForInterestPosting() { + return false; + } + + protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { + SavingsAccountTransaction postingTransation = null; + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { + postingTransation = transaction; + break; + } + } + return postingTransation; + } + + protected SavingsAccountTransaction findInterestPostingSavingsTransactionWithPivotConfig(final LocalDate postingDate) { + SavingsAccountTransaction postingTransation = null; + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { + postingTransation = transaction; + break; + } + } + return postingTransation; + } + + protected SavingsAccountTransaction findTransactionFor(final LocalDate postingDate, + final List transactions) { + SavingsAccountTransaction transaction = null; + for (final SavingsAccountTransaction savingsAccountTransaction : transactions) { + if (savingsAccountTransaction.occursOn(postingDate)) { + transaction = savingsAccountTransaction; + break; + } + } + return transaction; + } + + private Map calculateTaxSplit(final BigDecimal amount, final LocalDate date, final int scale) { + if (this.taxGroup == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return new HashMap<>(); + } + ClientKyc kyc = this.client != null ? this.client.getClientKyc() : null; + + // 1. Form 15G/15H Waiver Check + boolean waiveTds = false; + if (kyc != null && Boolean.TRUE.equals(kyc.getForm15Applicable())) { + String fy = kyc.getForm15Fy(); + if (isDateInFinancialYear(date, fy)) { + waiveTds = true; + } + } + + if (waiveTds) { + return new HashMap<>(); + } + + // 2. Determine if 20% higher rate applies (missing/unlinked PAN) + boolean applyHigherTdsRate = false; + if (kyc != null) { + boolean hasVerifiedPan = kyc.isPanVerified() || Boolean.TRUE.equals(kyc.getPanDeclarationTaken()); + String seedingStatus = kyc.getPanAadhaarSeedingStatus(); + + if (!hasVerifiedPan || "N".equalsIgnoreCase(seedingStatus)) { + applyHigherTdsRate = true; + } + } else { + // Default to higher rate (20%) if no KYC record is found + applyHigherTdsRate = true; + } + + // 3. Split tax + if (applyHigherTdsRate) { + return TaxUtils.splitTaxWithOverride(amount, date, this.taxGroup.getTaxGroupMappings(), scale, new BigDecimal("20.00")); + } else { + return TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), scale); + } + } + + private boolean isDateInFinancialYear(final LocalDate date, final String fy) { + if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { + return false; + } + try { + int startYear = Integer.parseInt(fy.substring(0, 4)); + LocalDate start = LocalDate.of(startYear, 4, 1); + LocalDate end = LocalDate.of(startYear + 1, 3, 31); + return !date.isBefore(start) && !date.isAfter(end); + } catch (Exception e) { + return false; + } + } + + protected boolean createWithHoldTransaction(final BigDecimal amount, final LocalDate date, final boolean backdatedTxnsAllowedTill) { + boolean isTaxAdded = false; + if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { + Map taxSplit = calculateTaxSplit(amount, date, amount.scale()); + BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); + if (totalTax.compareTo(BigDecimal.ZERO) > 0) { + SavingsAccountTransaction withholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), date, + Money.of(currency, totalTax), taxSplit); + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(withholdTransaction); + } else { + addTransaction(withholdTransaction); + } + isTaxAdded = true; + } + } + return isTaxAdded; + } + + protected boolean updateWithHoldTransaction(final BigDecimal amount, final SavingsAccountTransaction withholdTransaction) { + boolean isTaxAdded = false; + if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { + Map taxSplit = calculateTaxSplit(amount, withholdTransaction.getTransactionDate(), amount.scale()); + BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); + if (totalTax.compareTo(BigDecimal.ZERO) > 0) { + if (withholdTransaction.getId() == null) { + withholdTransaction.setAmount(Money.of(currency, totalTax)); + withholdTransaction.getTaxDetails().clear(); + SavingsAccountTransaction.updateTaxDetails(taxSplit, withholdTransaction); + isTaxAdded = true; + } else if (totalTax.compareTo(withholdTransaction.getAmount()) != 0) { + withholdTransaction.reverse(); + SavingsAccountTransaction newWithholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), + withholdTransaction.getTransactionDate(), Money.of(currency, totalTax), taxSplit); + addTransaction(newWithholdTransaction); + isTaxAdded = true; + } + } + } + return isTaxAdded; + } + + // Determine the last transaction for given day + protected SavingsAccountTransaction findLastTransaction(final LocalDate date) { + + SavingsAccountTransaction savingsTransaction = null; + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { + savingsTransaction = transaction; + break; + } + } + + return savingsTransaction; + } + + /** + * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. + *

+ * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening + * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the + * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match + * would leave the opening balance at zero on any pivot date that happens to carry no transaction. + *

+ * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final + * match is the latest one. + */ + protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { + SavingsAccountTransaction candidate = null; + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && DateUtils.isBefore(transaction.getTransactionDate(), date)) { + candidate = transaction; + } + } + return candidate; + } + + protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { + SavingsAccountTransaction savingsTransaction = null; + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { + savingsTransaction = transaction; + break; + } + } + return savingsTransaction; + } + + public List getManualPostingDates() { + List transactions = new ArrayList<>(); + for (SavingsAccountTransaction trans : this.transactions) { + if (trans.isInterestPosting() && trans.isNotReversed() && !trans.isReversalTransaction() && trans.isManualTransaction()) { + transactions.add(trans.getTransactionDate()); + } + } + return transactions; + } + + public List getManualPostingDatesWithPivotConfig() { + List transactions = new ArrayList<>(); + for (SavingsAccountTransaction trans : this.savingsAccountTransactions) { + if (trans.isInterestPosting() && trans.isNotReversed() && trans.isManualTransaction()) { + transactions.add(trans.getTransactionDate()); + } + } + return transactions; + } + + /** + * All interest calculation based on END-OF-DAY-BALANCE. + * + * Interest calculation is performed on-the-fly over all account transactions. + * + * + * 1. Calculate Interest From Beginning Of Account 1a. determine the 'crediting' periods that exist for this savings + * acccount 1b. determine the 'compounding' periods that exist within each 'crediting' period calculate the amount + * of interest due at the end of each 'crediting' period check if an existing 'interest posting' transaction exists + * for date and matches the amount posted + * + * @param isInterestTransfer + * TODO + */ + + public List calculateInterestUsing(final MathContext mc, final LocalDate upToInterestCalculationDate, + boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + + // no openingBalance concept supported yet but probably will to allow for migrations. + // Check global configurations and 'pivot' date is null + Money openingAccountBalance = backdatedTxnsAllowedTill ? Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()) + : Money.zero(this.currency); + + // update existing transactions so derived balance fields are correct. + recalculateDailyBalances(openingAccountBalance, upToInterestCalculationDate, backdatedTxnsAllowedTill, postReversals); + + final List allPostingPeriods = new ArrayList<>(); + if (hasInterestCalculation() || hasOverdraftInterestCalculation()) { + // 1. default to calculate interest based on entire history OR + // 2. determine latest 'posting period' and find interest credited to that period + + // A generate list of EndOfDayBalances (not including interest postings) + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType + .fromInt(this.interestPostingPeriodType); + + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + List postedAsOnDates = null; + if (backdatedTxnsAllowedTill) { + postedAsOnDates = getManualPostingDatesWithPivotConfig(); + } else { + postedAsOnDates = getManualPostingDates(); + } + if (postInterestOnDate != null) { + postedAsOnDates.add(postInterestOnDate); + } + final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( + getStartInterestCalculationDate(), upToInterestCalculationDate, postingPeriodType, financialYearBeginningMonth, + postedAsOnDates); + + Money periodStartingBalance; + if (this.startInterestCalculationDate != null && !this.getStartInterestCalculationDate().equals(this.getActivationDate())) { + LocalDate startInterestCalculationDate = this.startInterestCalculationDate; + SavingsAccountTransaction transaction = null; + if (backdatedTxnsAllowedTill) { + transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); + } else { + // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the + // first posting period and is applied there, so seeding from it would apply it twice -- + // and an exact-date match would leave this at zero whenever the pivot date carries no + // transaction of its own, silently accruing the whole term on a zero balance. + transaction = findLastTransactionBefore(startInterestCalculationDate); + } + + if (transaction == null) { + periodStartingBalance = Money.zero(this.currency); + } else if (backdatedTxnsAllowedTill) { + periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } else { + // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the + // assembler on the backdated-transactions path, so reading it here returned zero and + // silently wiped the opening balance. The balance carried by the last transaction + // before the start date is the same figure, and is always present. + periodStartingBalance = transaction.getRunningBalance(this.currency); + } + } else { + periodStartingBalance = Money.zero(this.currency); + } + + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType + .fromInt(this.interestCalculationType); + final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, upToInterestCalculationDate); + final BigDecimal overdraftInterestRateAsFraction = getEffectiveOverdraftInterestRateAsFraction(mc); + final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); + final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); + final Money minOverdraftForInterestCalculation = Money.of(getCurrency(), this.minOverdraftForInterestCalculation); + + for (final LocalDateInterval periodInterval : postingPeriodIntervals) { + + boolean isUserPosting = false; + if (postedAsOnDates.contains(periodInterval.endDate().plusDays(1))) { + isUserPosting = true; + } + + PostingPeriod postingPeriod = null; + List orderedNonInterestPostingTransactions = null; + if (backdatedTxnsAllowedTill) { + orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig(); + } else { + orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingTransactions(); + } + + List savingsAccountTransactionDetailsForPostingPeriod = toSavingsAccountTransactionDetailsForPostingPeriodList( + orderedNonInterestPostingTransactions); + + postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, + savingsAccountTransactionDetailsForPostingPeriod, this.currency, compoundingPeriodType, interestCalculationType, + interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, interestPostTransactions, + isInterestTransfer, minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, + overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, isUserPosting, financialYearBeginningMonth); + + periodStartingBalance = postingPeriod.closingBalance(); + + allPostingPeriods.add(postingPeriod); + } + + this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), + isTransferInterestToOtherAccount()); + + this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods); + } + + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } else { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + return allPostingPeriods; + } + + private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { + if (this.nominalAnnualInterestRateOverdraft == null) { + return BigDecimal.ZERO; + } + return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); + } + + public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { + return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); + } + + @SuppressWarnings("unused") + protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate upToInterestCalculationDate) { + return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); + } + + private boolean hasInterestCalculation() { + return !MathUtil.isEmpty(nominalAnnualInterestRate); + } + + private boolean hasOverdraftInterestCalculation() { + return isAllowOverdraft() && !MathUtil.isEmpty(getOverdraftLimit()) && !MathUtil.isEmpty(nominalAnnualInterestRateOverdraft); + } + + public List retrieveOrderedAccrualTransactions() { + return retrieveListOfTransactions().stream().filter(SavingsAccountTransaction::isAccrual) + .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); + } + + /** + * Transactions that should NOT count as part of this account's own balance history for interest + * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an + * input to it) AND, for accounts that transfer their interest out to a linked account + * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later + * sweeps that same interest back out (see TransferInterestToSavingsTasklet). + *

+ * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here + * -- the debit half stays, permanently understating this account's balance (and therefore every + * subsequent period's interest) by that amount from the first transfer onward, even though the real + * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the + * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this + * account, so excluding those ids here closes that gap. + */ + protected Collection interestTransferWithdrawalTransactionIds() { + if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(getId()); + } + + /** + * The given transactions minus the interest-transfer withdrawal legs, exactly as + * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever + * sees them. + *

+ * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's + * balance segment by walking backwards and ending the previous segment one day before the next + * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a + * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose + * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently + * cost the very first period one earning day. + */ + protected List excludeInterestTransferWithdrawals( + final List transactions) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + + protected List retreiveOrderedNonInterestPostingTransactions() { + final List listOfTransactionsSorted = retrieveListOfTransactions(); + + final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + + for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { + if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { + orderedNonInterestPostingTransactions.add(transaction); + } + } + orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); + return orderedNonInterestPostingTransactions; + } + + protected List retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig() { + final List listOfTransactionsSorted = retrieveSortedTransactions(); + + final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + + for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { + if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { + orderedNonInterestPostingTransactions.add(transaction); + } + } + orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); + return orderedNonInterestPostingTransactions; + } + + protected List retrieveSortedTransactions() { + final List listOfTransactionsSorted = new ArrayList<>(); + listOfTransactionsSorted.addAll(this.savingsAccountTransactions); + final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); + Collections.sort(listOfTransactionsSorted, transactionComparator); + return listOfTransactionsSorted; + } + + protected List retrieveListOfTransactions() { + final List listOfTransactionsSorted = new ArrayList<>(); + listOfTransactionsSorted.addAll(this.transactions); + + final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); + Collections.sort(listOfTransactionsSorted, transactionComparator); + return listOfTransactionsSorted; + } + + protected void recalculateDailyBalances(final Money openingAccountBalance, final LocalDate interestPostingUpToDate, + final boolean backdatedTxnsAllowedTill, boolean postReversals) { + Money runningBalance = openingAccountBalance; + boolean calculateInterest = hasInterestCalculation() || hasOverdraftInterestCalculation(); + + List accountTransactionsSorted = null; + + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + + boolean isTransactionsModified = false; + for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { + if (transaction.isReversed() || transaction.isReversalTransaction()) { + transaction.zeroBalanceFields(); + } else { + Money overdraftAmount = Money.zero(this.currency); + Money transactionAmount = Money.zero(this.currency); + if ((transaction.isCredit() || transaction.isAmountRelease())) { + if (runningBalance.isLessThanZero()) { + Money diffAmount = transaction.getAmount(this.currency).plus(runningBalance); + if (diffAmount.isGreaterThanZero()) { + overdraftAmount = transaction.getAmount(this.currency).minus(diffAmount); + } else { + overdraftAmount = transaction.getAmount(this.currency); + } + } + transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); + } else if (transaction.isDebit() || transaction.isAmountOnHold()) { + if (runningBalance.isLessThanZero()) { + overdraftAmount = transaction.getAmount(this.currency); + } + transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); + } + + runningBalance = runningBalance.plus(transactionAmount); + transaction.setRunningBalance(runningBalance); + + if (MathUtil.isEmpty(overdraftAmount) && runningBalance.isLessThanZero() && !transaction.isAmountOnHold()) { + overdraftAmount = runningBalance.negated(); + } + if (!calculateInterest || transaction.getId() == null || transaction.getOverdraftAmount(this.currency).isZero()) { + transaction.setOverdraftAmount(overdraftAmount); + } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency)) + && !transaction.isAccrual()) { + SavingsAccountTransaction accountTransaction = SavingsAccountTransaction.copyTransaction(transaction); + if (transaction.isChargeTransaction()) { + Set chargesPaidBy = transaction.getSavingsAccountChargesPaid(); + final Set newChargePaidBy = new HashSet<>(); + chargesPaidBy.forEach(x -> newChargePaidBy + .add(SavingsAccountChargePaidBy.instance(accountTransaction, x.getSavingsAccountCharge(), x.getAmount()))); + accountTransaction.getSavingsAccountChargesPaid().addAll(newChargePaidBy); + } + SavingsAccountTransaction reversal = null; + transaction.reverse(); + if (postReversals) { + reversal = SavingsAccountTransaction.reversal(transaction); + } + if (MathUtil.isGreaterThanZero(overdraftAmount)) { + accountTransaction.setOverdraftAmount(overdraftAmount); + } + accountTransaction.setRunningBalance(runningBalance); + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(accountTransaction); + if (reversal != null) { + addTransactionToExisting(reversal); + } + } else { + addTransaction(accountTransaction); + if (reversal != null) { + addTransaction(reversal); + } + } + isTransactionsModified = true; + } + + } + } + + if (isTransactionsModified) { + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + } + resetAccountTransactionsEndOfDayBalances(accountTransactionsSorted, interestPostingUpToDate); + } + + protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, + final LocalDate interestPostingUpToDate) { + // loop over transactions in reverse + LocalDate endOfBalanceDate = interestPostingUpToDate; + for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { + final SavingsAccountTransaction transaction = accountTransactionsSorted.get(i); + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { + transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); + // this transactions transaction date is end of balance date for + // previous transaction. + endOfBalanceDate = transaction.getTransactionDate().minusDays(1); + } + } + } + + public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String refNo) { + return deposit(transactionDTO, SavingsAccountTransactionType.DEPOSIT, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + refNo); + } + + public SavingsAccountTransaction dividendPayout(final SavingsAccountTransactionDTO transactionDTO, + final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate) { + String refNo = null; + return deposit(transactionDTO, SavingsAccountTransactionType.DIVIDEND_PAYOUT, backdatedTxnsAllowedTill, + relaxingDaysConfigForPivotDate, refNo); + } + + public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, + final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String refNo) { + final String resourceTypeName = depositAccountType().resourceName(); + if (isNotActive()) { + final String defaultUserMessage = "Transaction is not allowed. Account is not active."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.account.is.not.active", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction date cannot be in the future."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg." + resourceTypeName + ".transaction.in.the.future", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { + final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getActivationDate().format(transactionDTO.getFormatter())).toArray(); + final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.before.activation.date", defaultUserMessage, "transactionDate", + defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + resourceTypeName); + + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_DEPOSIT, transactionDTO.getTransactionDate()); + + final Money amount = Money.of(this.currency, transactionDTO.getTransactionAmount()); + + final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(this, office(), transactionDTO.getPaymentDetail(), + transactionDTO.getTransactionDate(), amount, savingsAccountTransactionType, refNo); + + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(transaction); + } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); + addTransaction(transaction); + } + + if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) + || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } + + return transaction; + } + + public void validatePivotDateTransaction(LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String resourceTypeName) { + if (backdatedTxnsAllowedTill) { + if (this.getSummary().getInterestPostedTillDate() != null && DateUtils.isBefore(transactionDate, + getSummary().getInterestPostedTillDate().minusDays(relaxingDaysConfigForPivotDate))) { + final Object[] defaultUserArgs = Arrays.asList(transactionDate, getActivationDate()).toArray(); + final String defaultUserMessage = "Transaction date cannot be before transactions pivot date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.before.pivot.date", defaultUserMessage, "transactionDate", + defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + + public LocalDate getActivationDate() { + return this.activatedOnDate; + } + + public AppUser getActivatedBy() { + return this.activatedBy; + } + + public LocalDate getWithdrawnOnDate() { + return this.withdrawnOnDate; + } + + public AppUser getWithdrawnBy() { + return this.withdrawnBy; + } + + // startInterestCalculationDate is set during migration so that there is no + // interference with interest posting of previous system + public LocalDate getStartInterestCalculationDate() { + LocalDate startInterestCalculationLocalDate = null; + if (this.startInterestCalculationDate != null) { + startInterestCalculationLocalDate = this.startInterestCalculationDate; + } else { + startInterestCalculationLocalDate = getActivationDate(); + } + return startInterestCalculationLocalDate; + } + + public void setStartInterestCalculationDate(LocalDate startInterestCalculationDate) { + this.startInterestCalculationDate = startInterestCalculationDate; + } + + public SavingsAccountTransaction withdraw(final SavingsAccountTransactionDTO transactionDTO, final boolean applyWithdrawFee, + final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate, String refNo) { + if (!isTransactionsAllowed()) { + + final String defaultUserMessage = "Transaction is not allowed. Account is not active."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.account.is.not.active", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction date cannot be in the future."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { + final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getActivationDate().format(transactionDTO.getFormatter())).toArray(); + final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.before.activation.date", + defaultUserMessage, "transactionDate", defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (isAccountLocked(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Withdrawal is not allowed. No withdrawals are allowed until after " + + getLockedInUntilDate().format(transactionDTO.getFormatter()); + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.savingsaccount.transaction.withdrawals.blocked.during.lockin.period", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getLockedInUntilDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + "savingsaccount"); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_WITHDRAWAL, transactionDTO.getTransactionDate()); + + if (applyWithdrawFee) { + // auto pay withdrawal fee + payWithdrawalFee(transactionDTO.getTransactionAmount(), transactionDTO.getTransactionDate(), transactionDTO.getPaymentDetail(), + backdatedTxnsAllowedTill, refNo); + } + + final Money transactionAmountMoney = Money.of(this.currency, transactionDTO.getTransactionAmount()); + final SavingsAccountTransaction transaction = SavingsAccountTransaction.withdrawal(this, office(), + transactionDTO.getPaymentDetail(), transactionDTO.getTransactionDate(), transactionAmountMoney, refNo); + + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(transaction); + } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); + addTransaction(transaction); + } + + if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) + || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } + return transaction; + } + + public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) { + BigDecimal result = BigDecimal.ZERO; + if (isWithdrawalFeeApplicableForTransfer()) { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isWithdrawalFee() && charge.isActive()) { + result = result.add(charge.calculateWithdralFeeAmount(transactionAmount), MoneyHelper.getMathContext()); + } + } + } + return result; + } + + private void payWithdrawalFee(final BigDecimal transactionAmount, final LocalDate transactionDate, final PaymentDetail paymentDetail, + final boolean backdatedTxnsAllowedTill, final String refNo) { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isWithdrawalFee() && charge.isActive()) { + + if (charge.getFreeWithdrawalCount() == null) { + charge.setFreeWithdrawalCount(0); + } + + if (charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction to + // specific paymentType + if (paymentDetail != null && paymentDetail.getPaymentType() != null + && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { + resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); + } + } else if (charge.isEnablePaymentType()) { // normal charge-transaction to specific paymentType + if (paymentDetail != null && paymentDetail.getPaymentType() != null + && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo); + } + } else if (!charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction + // irrespective of + // PaymentTypes. + resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); + + } else { // normal-withdraw + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo); + } + } + } + } + + private void resetFreeChargeDaysCount(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, + final String refNo) { + LocalDate resetDate = charge.getResetChargeDate(); + + Integer restartPeriod = charge.getRestartFrequency(); + if (charge.getRestartFrequencyEnum() == 2) { // calculation for months + LocalDate localDate = DateUtils.getBusinessLocalDate(); + + LocalDate resetLocalDate; + if (resetDate == null) { + resetLocalDate = this.activatedOnDate; + } else { + resetLocalDate = resetDate; + } + + LocalDate gapIntervalMonth = resetLocalDate.plusMonths(restartPeriod); + + YearMonth gapYearMonth = YearMonth.from(gapIntervalMonth); + YearMonth localYearMonth = YearMonth.from(localDate); + if (localYearMonth.isBefore(gapYearMonth)) { + countValidation(charge, transactionAmount, transactionDate, refNo); + } else { + discountCharge(1, charge); + } + } else { // calculation for days + long completedDays; + + if (resetDate == null) { + completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), this.activatedOnDate); + + } else { + completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), resetDate); + } + + int totalDays = (int) completedDays; + + if (totalDays < restartPeriod) { + countValidation(charge, transactionAmount, transactionDate, refNo); + } else { + discountCharge(1, charge); + } + } + } + + private void countValidation(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, + final String refNo) { + boolean backdatedTxnsAllowedTill = false; + if (charge.getFreeWithdrawalCount() < charge.getFrequencyFreeWithdrawalCharge()) { + final Integer count = charge.getFreeWithdrawalCount() + 1; + charge.setFreeWithdrawalCount(count); + charge.updateNoWithdrawalFee(); + } else { + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, refNo); + } + } + + private void discountCharge(Integer freeWithdrawalCount, SavingsAccountCharge charge) { + charge.setFreeWithdrawalCount(freeWithdrawalCount); + charge.setDiscountDueDate(DateUtils.getBusinessLocalDate()); + charge.updateNoWithdrawalFee(); + } + + public boolean isBeforeLastPostingPeriod(final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill) { + + boolean transactionBeforeLastInterestPosting = false; + + if (!backdatedTxnsAllowedTill) { + for (final SavingsAccountTransaction transaction : retrieveListOfTransactions()) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isAfter(transactionDate) && !transaction.isReversalTransaction()) { + transactionBeforeLastInterestPosting = true; + break; + } + } + } else { + if (this.summary.getInterestPostedTillDate() == null) { + return false; + } + transactionBeforeLastInterestPosting = DateUtils.isBefore(transactionDate, this.summary.getInterestPostedTillDate()); + } + + return transactionBeforeLastInterestPosting; + } + + public void validateAccountBalanceDoesNotBecomeNegative(final BigDecimal transactionAmount, final boolean isException, + final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { + + List transactionsSortedByDate = null; + + if (backdatedTxnsAllowedTill) { + transactionsSortedByDate = retrieveSortedTransactions(); + } else { + transactionsSortedByDate = retrieveListOfTransactions(); + } + + Money runningBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + LocalDate lastSavingsDate = null; + final BigDecimal withdrawalFee = null; + for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { + if (transaction.isNotReversed() && transaction.isCredit() && !transaction.isReversalTransaction()) { + runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); + } else if (transaction.isNotReversed() && transaction.isDebit() && !transaction.isReversalTransaction()) { + runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); + } else { + continue; + } + + /* + * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the + * point in time the transaction was made: + */ + if (depositAccountOnHoldTransactions != null) { + for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { + // Compare the balance of the on hold: + if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) + && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { + if (onHoldTransaction.getTransactionType().isHold()) { + minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); + } else { + minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); + } + } + } + } + + // deal with potential minRequiredBalance and + // enforceMinRequiredBalance + if (!isException && transaction.canProcessBalanceCheck() && !isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } + lastSavingsDate = transaction.getTransactionDate(); + + } + + // In overdraft cases, minRequiredBalance can be in violation after + // interest posting + // and should be checked after processing all transactions + if (isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + + if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { + if (this.enforceMinRequiredBalance) { + if (runningBalance.minus(minRequiredBalance.plus(this.getSavingsHoldAmount())).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } else { + if (runningBalance.minus(this.getSavingsHoldAmount()).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } + } + } + + public void validateAccountBalanceDoesNotBecomeNegative(final String transactionAction, + final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { + + List transactionsSortedByDate = null; + BigDecimal transactionAmount = null; + if (backdatedTxnsAllowedTill) { + transactionsSortedByDate = retrieveSortedTransactions(); + } else { + transactionsSortedByDate = retrieveListOfTransactions(); + } + Money runningBalance = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + LocalDate lastSavingsDate = null; + for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { + + transactionAmount = transaction.getAmount(); + + if (transaction.isNotReversed() && transaction.isCredit()) { + runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); + } else if (transaction.isNotReversed() && transaction.isDebit()) { + runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); + } + + /* + * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the + * point in time the transaction was made: + */ + if (depositAccountOnHoldTransactions != null) { + for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { + // Compare the balance of the on hold: + if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) + && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { + if (onHoldTransaction.getTransactionType().isHold()) { + minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); + } else { + minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); + } + } + } + } + + // enforceMinRequiredBalance + if (transaction.canProcessBalanceCheck()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + transactionAction); + if (!this.allowOverdraft) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.going.negative"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + lastSavingsDate = transaction.getTransactionDate(); + } + + BigDecimal withdrawalFee = null; + if (isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + } + + public void validateAccountBalanceDoesNotViolateOverdraft(final List savingsAccountTransaction, + final BigDecimal amountPaid) { + if (savingsAccountTransaction != null && savingsAccountTransaction.size() > 0) { + SavingsAccountTransaction savingsAccountTransactionFirst = savingsAccountTransaction.get(0); + if (!this.allowOverdraft) { + if (savingsAccountTransactionFirst.getRunningBalance(this.currency).minus(amountPaid).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), null, amountPaid); + } + } + + } else { + if (!this.allowOverdraft) { + throw new InsufficientAccountBalanceException("transactionAmount", BigDecimal.ZERO, null, amountPaid); + } + } + } + + protected boolean isAccountLocked(final LocalDate transactionDate) { + return DateUtils.isBefore(transactionDate, getLockedInUntilDate()); + } + + public LocalDate getLockedInUntilDate() { + return this.lockedInUntilDate; + } + + public BigDecimal getAccountBalance() { + return this.summary.getAccountBalance(this.currency).getAmount(); + } + + public void modifyApplication(final JsonCommand command, final Map actualChanges) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); + this.modifyApplication(command, actualChanges, baseDataValidator); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public void modifyApplication(final JsonCommand command, final Map actualChanges, + final DataValidatorBuilder baseDataValidator) { + + final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); + final boolean isAllowedState = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus) + || SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus); + if (!isAllowedState) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + return; + } + // If modifying a rejected application, reset it back to pending approval + if (SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus)) { + this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); + this.rejectedOnDate = null; + this.rejectedBy = null; + actualChanges.put("status", SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue()); + } + + final String localeAsInput = command.locale(); + final String dateFormat = command.dateFormat(); + + if (command.isChangeInLocalDateParameterNamed(SavingsApiConstants.submittedOnDateParamName, getSubmittedOnDate())) { + final String newValueAsString = command.stringValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); + actualChanges.put(SavingsApiConstants.submittedOnDateParamName, newValueAsString); + actualChanges.put(SavingsApiConstants.localeParamName, localeAsInput); + actualChanges.put(SavingsApiConstants.dateFormatParamName, dateFormat); + this.submittedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); + } + if (command.isChangeInStringParameterNamed(SavingsApiConstants.accountNoParamName, this.accountNumber)) { + final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.accountNoParamName); + actualChanges.put(SavingsApiConstants.accountNoParamName, newValue); + this.accountNumber = StringUtils.defaultIfEmpty(newValue, null); + } + if (command.isChangeInStringParameterNamed(SavingsApiConstants.externalIdParamName, this.externalId.getValue())) { + final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.externalIdParamName); + actualChanges.put(SavingsApiConstants.externalIdParamName, newValue); + this.externalId = ExternalIdFactory.produce(newValue); + if (this.externalId.isEmpty() && TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled()) { + this.externalId = ExternalId.generate(); + } + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.clientIdParamName, clientId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.clientIdParamName); + actualChanges.put(SavingsApiConstants.clientIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.groupIdParamName, groupId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.groupIdParamName); + actualChanges.put(SavingsApiConstants.groupIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.productIdParamName, this.product.getId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.productIdParamName); + actualChanges.put(SavingsApiConstants.productIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.fieldOfficerIdParamName, hasSavingsOfficerId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.fieldOfficerIdParamName); + actualChanges.put(SavingsApiConstants.fieldOfficerIdParamName, newValue); + } + if (command.isChangeInBigDecimalParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName, + this.nominalAnnualInterestRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName); + actualChanges.put(SavingsApiConstants.nominalAnnualInterestRateParamName, newValue); + this.nominalAnnualInterestRate = newValue; + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName, + this.interestCompoundingPeriodType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName); + this.interestCompoundingPeriodType = newValue != null ? SavingsCompoundingInterestPeriodType.fromInt(newValue).getValue() + : newValue; + actualChanges.put(SavingsApiConstants.interestCompoundingPeriodTypeParamName, this.interestCompoundingPeriodType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName, + this.interestPostingPeriodType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName); + this.interestPostingPeriodType = newValue != null ? SavingsPostingInterestPeriodType.fromInt(newValue).getValue() : newValue; + actualChanges.put(SavingsApiConstants.interestPostingPeriodTypeParamName, this.interestPostingPeriodType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationTypeParamName); + this.interestCalculationType = newValue != null ? SavingsInterestCalculationType.fromInt(newValue).getValue() : newValue; + actualChanges.put(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, + this.interestCalculationDaysInYearType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName); + this.interestCalculationDaysInYearType = newValue != null + ? SavingsInterestCalculationDaysInYearType.fromInt(newValue).getValue() + : newValue; + actualChanges.put(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, this.interestCalculationDaysInYearType); + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(SavingsApiConstants.minRequiredOpeningBalanceParamName, + this.minRequiredOpeningBalance)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.minRequiredOpeningBalanceParamName); + actualChanges.put(SavingsApiConstants.minRequiredOpeningBalanceParamName, newValue); + actualChanges.put("locale", localeAsInput); + this.minRequiredOpeningBalance = Money.of(this.currency, newValue).getAmount(); + } + if (command.isChangeInIntegerParameterNamedDefaultingZeroToNull(SavingsApiConstants.lockinPeriodFrequencyParamName, + this.lockinPeriodFrequency)) { + final Integer newValue = command + .integerValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.lockinPeriodFrequencyParamName); + actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyParamName, newValue); + actualChanges.put("locale", localeAsInput); + this.lockinPeriodFrequency = newValue; + } + + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, + this.lockinPeriodFrequencyType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName); + actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, newValue); + this.lockinPeriodFrequencyType = newValue != null ? SavingsPeriodFrequencyType.fromInt(newValue).getValue() : newValue; + } + + // set period type to null if frequency is null + if (this.lockinPeriodFrequency == null) { + this.lockinPeriodFrequencyType = null; + } + + if (command.isChangeInBooleanParameterNamed(withdrawalFeeForTransfersParamName, this.withdrawalFeeApplicableForTransfer)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withdrawalFeeForTransfersParamName); + actualChanges.put(withdrawalFeeForTransfersParamName, newValue); + this.withdrawalFeeApplicableForTransfer = newValue; + } + + // charges + final String chargesParamName = "charges"; + if (command.hasParameter(chargesParamName)) { + final JsonArray jsonArray = command.arrayOfParameterNamed(chargesParamName); + if (jsonArray != null) { + actualChanges.put(chargesParamName, command.jsonFragment(chargesParamName)); + } + } + + if (command.isChangeInBooleanParameterNamed(allowOverdraftParamName, this.allowOverdraft)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(allowOverdraftParamName); + actualChanges.put(allowOverdraftParamName, newValue); + this.allowOverdraft = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(overdraftLimitParamName, this.overdraftLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(overdraftLimitParamName); + actualChanges.put(overdraftLimitParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.overdraftLimit = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(nominalAnnualInterestRateOverdraftParamName, + this.nominalAnnualInterestRateOverdraft)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(nominalAnnualInterestRateOverdraftParamName); + actualChanges.put(nominalAnnualInterestRateOverdraftParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.nominalAnnualInterestRateOverdraft = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minOverdraftForInterestCalculationParamName, + this.minOverdraftForInterestCalculation)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(minOverdraftForInterestCalculationParamName); + actualChanges.put(minOverdraftForInterestCalculationParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.minOverdraftForInterestCalculation = newValue; + } + + if (!this.allowOverdraft) { + this.overdraftLimit = null; + this.nominalAnnualInterestRateOverdraft = null; + this.minOverdraftForInterestCalculation = null; + } + + if (command.isChangeInBooleanParameterNamed(enforceMinRequiredBalanceParamName, this.enforceMinRequiredBalance)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(enforceMinRequiredBalanceParamName); + actualChanges.put(enforceMinRequiredBalanceParamName, newValue); + this.enforceMinRequiredBalance = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minRequiredBalanceParamName, this.minRequiredBalance)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(minRequiredBalanceParamName); + actualChanges.put(minRequiredBalanceParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.minRequiredBalance = newValue; + } + if (command.isChangeInBooleanParameterNamed(lienAllowedParamName, this.lienAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(lienAllowedParamName); + actualChanges.put(lienAllowedParamName, newValue); + this.lienAllowed = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(maxAllowedLienLimitParamName, this.maxAllowedLienLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(maxAllowedLienLimitParamName); + actualChanges.put(maxAllowedLienLimitParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.maxAllowedLienLimit = newValue; + } + if (command.isChangeInBooleanParameterNamed(withHoldTaxParamName, this.withHoldTax)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); + actualChanges.put(withHoldTaxParamName, newValue); + this.withHoldTax = newValue; + if (this.withHoldTax && this.taxGroup == null) { + baseDataValidator.reset().parameter(withHoldTaxParamName).failWithCode("not.supported.for.this.account"); + } + } + + if (command.isChangeInBooleanParameterNamed(introducerRequiredParamName, this.introducerRequired)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(introducerRequiredParamName); + actualChanges.put(introducerRequiredParamName, newValue); + this.introducerRequired = newValue; + } + + if (command.isChangeInBooleanParameterNamed(shareLinkingEnabledParamName, this.shareLinkingEnabled)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(shareLinkingEnabledParamName); + actualChanges.put(shareLinkingEnabledParamName, newValue); + this.shareLinkingEnabled = newValue; + } + + // CBS extended fields mapping during modify application + final String accountTypeParam = "accountType"; + if (command.isChangeInStringParameterNamed(accountTypeParam, this.cbsAccountType)) { + final String newValue = command.stringValueOfParameterNamed(accountTypeParam); + actualChanges.put(accountTypeParam, newValue); + this.cbsAccountType = newValue; + } + + final String customerSegmentParam = "customerSegment"; + if (command.isChangeInStringParameterNamed(customerSegmentParam, this.customerSegment)) { + final String newValue = command.stringValueOfParameterNamed(customerSegmentParam); + actualChanges.put(customerSegmentParam, newValue); + this.customerSegment = newValue; + } + + final String accountStatusParam = "accountStatus"; + if (command.isChangeInStringParameterNamed(accountStatusParam, this.accountStatus)) { + final String newValue = command.stringValueOfParameterNamed(accountStatusParam); + actualChanges.put(accountStatusParam, newValue); + this.accountStatus = newValue; + } + + final String modeOfOperationParam = "modeOfOperation"; + if (command.isChangeInStringParameterNamed(modeOfOperationParam, this.modeOfOperation)) { + final String newValue = command.stringValueOfParameterNamed(modeOfOperationParam); + actualChanges.put(modeOfOperationParam, newValue); + this.modeOfOperation = newValue; + } + + final String specialCategoryParam = "specialCategory"; + if (command.isChangeInStringParameterNamed(specialCategoryParam, this.specialCategory)) { + final String newValue = command.stringValueOfParameterNamed(specialCategoryParam); + actualChanges.put(specialCategoryParam, newValue); + this.specialCategory = newValue; + } + + final String weakerSectionParam = "weakerSection"; + if (command.isChangeInStringParameterNamed(weakerSectionParam, this.weakerSection)) { + final String newValue = command.stringValueOfParameterNamed(weakerSectionParam); + actualChanges.put(weakerSectionParam, newValue); + this.weakerSection = newValue; + } + + final String nominationFacilityParam = "nominationFacility"; + if (command.isChangeInBooleanParameterNamed(nominationFacilityParam, this.nominationFacility)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(nominationFacilityParam); + actualChanges.put(nominationFacilityParam, newValue); + this.nominationFacility = newValue; + } + + final String autoDebitFacilityParam = "autoDebitFacility"; + if (command.isChangeInBooleanParameterNamed(autoDebitFacilityParam, this.autoDebitFacility)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(autoDebitFacilityParam); + actualChanges.put(autoDebitFacilityParam, newValue); + this.autoDebitFacility = newValue; + } + + final String isSeniorCitizenParam = "isSeniorCitizen"; + if (command.isChangeInBooleanParameterNamed(isSeniorCitizenParam, this.isSeniorCitizen)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(isSeniorCitizenParam); + actualChanges.put(isSeniorCitizenParam, newValue); + this.isSeniorCitizen = newValue; + } + + final String seniorCitizenInterestRateParam = "seniorCitizenInterestRate"; + if (command.isChangeInBigDecimalParameterNamed(seniorCitizenInterestRateParam, this.seniorCitizenInterestRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(seniorCitizenInterestRateParam); + actualChanges.put(seniorCitizenInterestRateParam, newValue); + this.seniorCitizenInterestRate = newValue; + if (newValue != null && newValue.compareTo(BigDecimal.ZERO) > 0) { + updateNominalAnnualInterestRate(newValue); + } + } + + final String guardianNameParam = "guardianName"; + if (command.isChangeInStringParameterNamed(guardianNameParam, this.guardianName)) { + final String newValue = command.stringValueOfParameterNamed(guardianNameParam); + actualChanges.put(guardianNameParam, newValue); + this.guardianName = newValue; + } + + final String guardianCifNumberParam = "guardianCifNumber"; + if (command.isChangeInStringParameterNamed(guardianCifNumberParam, this.guardianCifNumber)) { + final String newValue = command.stringValueOfParameterNamed(guardianCifNumberParam); + actualChanges.put(guardianCifNumberParam, newValue); + this.guardianCifNumber = newValue; + } + + final String guardianRelationshipParam = "guardianRelationship"; + if (command.isChangeInStringParameterNamed(guardianRelationshipParam, this.guardianRelationship)) { + final String newValue = command.stringValueOfParameterNamed(guardianRelationshipParam); + actualChanges.put(guardianRelationshipParam, newValue); + this.guardianRelationship = newValue; + } + + final String minorDateOfBirthParam = "minorDateOfBirth"; + if (command.isChangeInLocalDateParameterNamed(minorDateOfBirthParam, this.minorDateOfBirth)) { + final LocalDate newValue = command.localDateValueOfParameterNamed(minorDateOfBirthParam); + actualChanges.put(minorDateOfBirthParam, newValue); + this.minorDateOfBirth = newValue; + } + + final String guardianClientIdParam = "guardianClientId"; + if (command.isChangeInLongParameterNamed(guardianClientIdParam, this.guardianClientId)) { + final Long newValue = command.longValueOfParameterNamed(guardianClientIdParam); + actualChanges.put(guardianClientIdParam, newValue); + this.guardianClientId = newValue; + } + + final String introducerIdParam = "introducerId"; + if (command.isChangeInLongParameterNamed(introducerIdParam, this.introducerClientId)) { + final Long newValue = command.longValueOfParameterNamed(introducerIdParam); + actualChanges.put(introducerIdParam, newValue); + this.introducerClientId = newValue; + } + + final String introducerNameParam = "introducerName"; + if (command.isChangeInStringParameterNamed(introducerNameParam, this.introducerName)) { + final String newValue = command.stringValueOfParameterNamed(introducerNameParam); + actualChanges.put(introducerNameParam, newValue); + this.introducerName = newValue; + } + + final String introducerCifNumberParam = "introducerCifNumber"; + if (command.isChangeInStringParameterNamed(introducerCifNumberParam, this.introducerCifNumber)) { + final String newValue = command.stringValueOfParameterNamed(introducerCifNumberParam); + actualChanges.put(introducerCifNumberParam, newValue); + this.introducerCifNumber = newValue; + } + + final String linkedAccountIdParam = "linkedAccountId"; + if (command.isChangeInLongParameterNamed(linkedAccountIdParam, this.linkedAccountId)) { + final Long newValue = command.longValueOfParameterNamed(linkedAccountIdParam); + actualChanges.put(linkedAccountIdParam, newValue); + this.linkedAccountId = newValue; + } + + final String linkedAccountTypeParam = "linkedAccountType"; + if (command.isChangeInStringParameterNamed(linkedAccountTypeParam, this.linkedAccountType)) { + final String newValue = command.stringValueOfParameterNamed(linkedAccountTypeParam); + actualChanges.put(linkedAccountTypeParam, newValue); + this.linkedAccountType = newValue; + } + + final String linkedAccountNoParam = "linkedAccountNo"; + if (command.isChangeInStringParameterNamed(linkedAccountNoParam, this.linkedAccountNo)) { + final String newValue = command.stringValueOfParameterNamed(linkedAccountNoParam); + actualChanges.put(linkedAccountNoParam, newValue); + this.linkedAccountNo = newValue; + } + + final String accountFacilityConfigParam = "accountFacilityConfig"; + if (command.hasParameter(accountFacilityConfigParam)) { + final com.google.gson.JsonElement parsedJson = command.parsedJson(); + if (parsedJson != null && parsedJson.isJsonObject()) { + final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); + if (jsonObj.has(accountFacilityConfigParam) && !jsonObj.get(accountFacilityConfigParam).isJsonNull() + && jsonObj.get(accountFacilityConfigParam).isJsonObject()) { + final com.google.gson.JsonObject configObj = jsonObj.get(accountFacilityConfigParam).getAsJsonObject(); + if (this.facilityConfig == null) { + this.facilityConfig = new SavingsAccountFacilityConfig(this); + } + this.facilityConfig.updateFrom(configObj); + actualChanges.put(accountFacilityConfigParam, configObj.toString()); + } + } + } + + final String jointHoldersParam = "jointHolders"; + if (command.hasParameter(jointHoldersParam)) { + final com.google.gson.JsonElement parsedJson = command.parsedJson(); + if (parsedJson != null && parsedJson.isJsonObject()) { + final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); + if (jsonObj.has(jointHoldersParam) && !jsonObj.get(jointHoldersParam).isJsonNull() + && jsonObj.get(jointHoldersParam).isJsonArray()) { + final com.google.gson.JsonArray holdersArray = jsonObj.get(jointHoldersParam).getAsJsonArray(); + final List holders = new ArrayList<>(); + for (int i = 0; i < holdersArray.size(); i++) { + final com.google.gson.JsonObject h = holdersArray.get(i).getAsJsonObject(); + final Long clientId = h.has("clientId") && !h.get("clientId").isJsonNull() + ? h.get("clientId").getAsLong() : null; + final String cifNumber = h.has("cifNumber") && !h.get("cifNumber").isJsonNull() + ? h.get("cifNumber").getAsString() : null; + final String displayName = h.has("displayName") && !h.get("displayName").isJsonNull() + ? h.get("displayName").getAsString() : null; + final BigDecimal sharePercentage = h.has("sharePercentage") && !h.get("sharePercentage").isJsonNull() + ? h.get("sharePercentage").getAsBigDecimal() : null; + final boolean isPrimary = h.has("isPrimary") && h.get("isPrimary").getAsBoolean(); + String signingAuthority = h.has("signingAuthority") && !h.get("signingAuthority").isJsonNull() + ? h.get("signingAuthority").getAsString() : null; + String role = h.has("role") && !h.get("role").isJsonNull() + ? h.get("role").getAsString() : null; + + if (signingAuthority == null || signingAuthority.trim().isEmpty()) { + signingAuthority = isPrimary ? "Mandatory" : "Optional"; + } + if (role == null || role.trim().isEmpty()) { + role = isPrimary ? "Primary Account Holder" : "Co-Holder"; + } + + final boolean isMandatory = h.has("isMandatory") && h.get("isMandatory").getAsBoolean(); + holders.add(new SavingsAccountJointHolder(this, clientId, cifNumber, displayName, + sharePercentage, signingAuthority, role, isPrimary, isMandatory)); + } + this.jointHolders.clear(); + this.jointHolders.addAll(holders); + actualChanges.put(jointHoldersParam, holdersArray.toString()); + } + } + } + + validateLockinDetails(baseDataValidator); + esnureOverdraftLimitsSetForOverdraftAccounts(); + } + + /** + * If overdrafts are allowed and the overdraft limit is not set, set the same to Zero + **/ + private void esnureOverdraftLimitsSetForOverdraftAccounts() { + + this.overdraftLimit = this.overdraftLimit == null ? BigDecimal.ZERO : this.overdraftLimit; + this.nominalAnnualInterestRateOverdraft = this.nominalAnnualInterestRateOverdraft == null ? BigDecimal.ZERO + : this.nominalAnnualInterestRateOverdraft; + this.minOverdraftForInterestCalculation = this.minOverdraftForInterestCalculation == null ? BigDecimal.ZERO + : this.minOverdraftForInterestCalculation; + } + + private void validateLockinDetails(final DataValidatorBuilder baseDataValidator) { + + /* + * final List dataValidationErrors = new ArrayList(); final + * DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + * .resource(resourceName); + */ + + if (this.lockinPeriodFrequency == null) { + baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() + .inMinMaxRange(0, 3); + + if (this.lockinPeriodFrequencyType != null) { + baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequency).ignoreIfNull() + .integerZeroOrGreater(); + } + } else { + baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequencyType) + .integerZeroOrGreater(); + baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() + .inMinMaxRange(0, 3); + } + } + + public Map deriveAccountingBridgeData(final String currencyCode, final Set existingTransactionIds, + final Set existingReversedTransactionIds, boolean isAccountTransfer, final boolean backdatedTxnsAllowedTill) { + + final Map accountingBridgeData = new LinkedHashMap<>(); + accountingBridgeData.put("savingsId", getId()); + accountingBridgeData.put("savingsProductId", productId()); + accountingBridgeData.put("currencyCode", currencyCode); + accountingBridgeData.put("officeId", officeId()); + accountingBridgeData.put("cashBasedAccountingEnabled", isCashBasedAccountingEnabledOnSavingsProduct()); + accountingBridgeData.put("accrualBasedAccountingEnabled", isAccrualBasedAccountingEnabledOnSavingsProduct()); + accountingBridgeData.put("isAccountTransfer", isAccountTransfer); + accountingBridgeData.put("depositAccountType", depositAccountType().getValue()); + + final List> newSavingsTransactions = new ArrayList<>(); + + List trans = null; + + if (backdatedTxnsAllowedTill) { + trans = getSavingsAccountTransactionsWithPivotConfig(); + } else { + trans = getTransactions(); + } + + // Adding new transactions to the array + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed() && !existingReversedTransactionIds.contains(transaction.getId())) { + newSavingsTransactions.add(transaction.toMapData(currencyCode)); + } else if (!existingTransactionIds.contains(transaction.getId())) { + newSavingsTransactions.add(transaction.toMapData(currencyCode)); + } + } + + accountingBridgeData.put("newSavingsTransactions", newSavingsTransactions); + return accountingBridgeData; + } + + public Collection findExistingTransactionIds() { + final Collection ids = new ArrayList<>(); + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + ids.add(transaction.getId()); + } + return ids; + } + + public Collection findCurrentTransactionIdsWithPivotDateConfig() { + + final Collection ids = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + ids.add(transaction.getId()); + } + return ids; + } + + public Collection findExistingReversedTransactionIds() { + + final Collection ids = new ArrayList<>(); + List trans = getTransactions(); + // time consuming + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed()) { + ids.add(transaction.getId()); + } + } + + return ids; + } + + public Collection findCurrentReversedTransactionIdsWithPivotDateConfig() { + final Collection ids = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + // time consuming + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed()) { + ids.add(transaction.getId()); + } + } + return ids; + } + + public void update(final Client client) { + this.client = client; + } + + public void update(final Group group) { + this.group = group; + } + + public void update(final SavingsProduct product) { + this.product = product; + this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); + } + + public void update(final Staff savingsOfficer) { + this.savingsOfficer = savingsOfficer; + } + + public void updateAccountNo(final String newAccountNo) { + this.accountNumber = newAccountNo; + this.accountNumberRequiresAutoGeneration = false; + } + + public boolean isAccountNumberRequiresAutoGeneration() { + return this.accountNumberRequiresAutoGeneration; + } + + public Long productId() { + return this.product.getId(); + } + + public SavingsProduct savingsProduct() { + return this.product; + } + + private Boolean isCashBasedAccountingEnabledOnSavingsProduct() { + return this.product.isCashBasedAccountingEnabled(); + } + + private Boolean isAccrualBasedAccountingEnabledOnSavingsProduct() { + return this.product.isAccrualBasedAccountingEnabled(); + } + + public Long officeId() { + Long officeId = null; + if (this.client != null) { + officeId = this.client.officeId(); + } else if (this.group != null) { + officeId = this.group.officeId(); + } + return officeId; + } + + public Office office() { + Office office = null; + if (this.client != null) { + office = this.client.getOffice(); + } else if (this.group != null) { + office = this.group.getOffice(); + } + return office; + } + + public Staff getSavingsOfficer() { + return this.savingsOfficer; + } + + public Boolean getEnforceMinRequiredBalance() { + return this.enforceMinRequiredBalance; + } + + public Boolean getLienAllowed() { + return this.lienAllowed; + } + + public void unassignSavingsOfficer() { + this.savingsOfficer = null; + } + + public void assignSavingsOfficer(final Staff fieldOfficer) { + this.savingsOfficer = fieldOfficer; + } + + public Long clientId() { + Long id = null; + if (this.client != null) { + id = this.client.getId(); + } + return id; + } + + public Long groupId() { + Long id = null; + if (this.group != null) { + id = this.group.getId(); + } + return id; + } + + public GroupSavingsIndividualMonitoring getGsim() { + return gsim; + } + + public Long getSavingsProductId() { + return this.savingsProduct().getId(); + } + + public void setGsim(GroupSavingsIndividualMonitoring gsim) { + this.gsim = gsim; + } + + public Long hasSavingsOfficerId() { + Long id = null; + if (this.savingsOfficer != null) { + id = this.savingsOfficer.getId(); + } + return id; + } + + public boolean hasSavingsOfficer(final Staff fromSavingsOfficer) { + + boolean matchesCurrentSavingsOfficer = false; + if (this.savingsOfficer != null) { + matchesCurrentSavingsOfficer = this.savingsOfficer.identifiedBy(fromSavingsOfficer); + } else { + matchesCurrentSavingsOfficer = fromSavingsOfficer == null; + } + return matchesCurrentSavingsOfficer; + } + + public void reassignSavingsOfficer(final Staff newSavingsOfficer, final LocalDate assignmentDate) { + final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); + final SavingsOfficerAssignmentHistory lastAssignmentRecord = findLastAssignmentHistoryRecord(newSavingsOfficer); + + // assignment date should not be less than savings account submitted date + if (DateUtils.isBefore(assignmentDate, getSubmittedOnDate())) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate.toString() + + ") cannot be before savings submitted date (" + getSubmittedOnDate().toString() + ")."; + throw new SavingsOfficerAssignmentDateException("cannot.be.before.savings.submitted.date", errorMessage, assignmentDate, + getSubmittedOnDate()); + } else if (lastAssignmentRecord != null && lastAssignmentRecord.isBeforeEndDate(assignmentDate)) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + + ") cannot be before previous Savings Officer unassigned date (" + lastAssignmentRecord.getEndDate() + ")."; + throw new SavingsOfficerAssignmentDateException("cannot.be.before.previous.unassignement.date", errorMessage, assignmentDate, + lastAssignmentRecord.getEndDate()); + } else if (DateUtils.isDateInTheFuture(assignmentDate)) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + ") cannot be in the future."; + throw new SavingsOfficerAssignmentDateException("cannot.be.a.future.date", errorMessage, assignmentDate); + } else if (latestHistoryRecord != null && this.savingsOfficer.identifiedBy(newSavingsOfficer)) { + latestHistoryRecord.setStartDate(assignmentDate); + } else if (latestHistoryRecord != null && latestHistoryRecord.matchesStartDateOf(assignmentDate)) { + latestHistoryRecord.setSavingsOfficer(newSavingsOfficer); + this.savingsOfficer = newSavingsOfficer; + } else if (latestHistoryRecord != null && latestHistoryRecord.isBeforeStartDate(assignmentDate)) { + final String errorMessage = "Savings account with identifier " + getId() + " was already assigned before date " + + assignmentDate; + throw new SavingsOfficerAssignmentDateException("is.before.last.assignment.date", errorMessage, getId(), assignmentDate); + } else { + if (latestHistoryRecord != null) { + // savings officer correctly changed from previous savings + // officer to + // new savings officer + latestHistoryRecord.setEndDate(assignmentDate); + } + this.savingsOfficer = newSavingsOfficer; + if (isNotSubmittedAndPendingApproval()) { + final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, + this.savingsOfficer, assignmentDate); + this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); + } + } + } + + private SavingsOfficerAssignmentHistory findLastAssignmentHistoryRecord(final Staff newSavingsOfficer) { + SavingsOfficerAssignmentHistory lastAssignmentRecordLatestEndDate = null; + for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { + if (historyRecord.isCurrentRecord() && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { + lastAssignmentRecordLatestEndDate = historyRecord; + break; + } + + if (lastAssignmentRecordLatestEndDate == null) { + lastAssignmentRecordLatestEndDate = historyRecord; + } else if (historyRecord.isBeforeEndDate(lastAssignmentRecordLatestEndDate.getEndDate()) + && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { + lastAssignmentRecordLatestEndDate = historyRecord; + } + } + return lastAssignmentRecordLatestEndDate; + } + + public LocalDate getSubmittedOnDate() { + return submittedOnDate; + } + + public LocalDate getApprovedOnDate() { + return approvedOnDate; + } + + public LocalDate getRejectedOnDate() { + return rejectedOnDate; + } + + public AppUser getRejectedBy() { + return this.rejectedBy; + } + + public void removeSavingsOfficer(final LocalDate unassignDate) { + final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); + + if (latestHistoryRecord != null) { + validateUnassignDate(latestHistoryRecord, unassignDate); + latestHistoryRecord.setEndDate(unassignDate); + } + this.savingsOfficer = null; + } + + private SavingsOfficerAssignmentHistory findLatestIncompleteHistoryRecord() { + SavingsOfficerAssignmentHistory latestRecordWithNoEndDate = null; + for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { + if (historyRecord.isCurrentRecord()) { + latestRecordWithNoEndDate = historyRecord; + break; + } + } + return latestRecordWithNoEndDate; + } + + private void validateUnassignDate(final SavingsOfficerAssignmentHistory latestHistoryRecord, final LocalDate unassignDate) { + if (DateUtils.isBefore(unassignDate, latestHistoryRecord.getStartDate())) { + final String errorMessage = "The Savings officer Unassign date(" + unassignDate + ") cannot be before its assignment date (" + + latestHistoryRecord.getStartDate() + ")."; + throw new SavingsOfficerUnassignmentDateException("cannot.be.before.assignment.date", errorMessage, getId(), + getSavingsOfficer().getId(), latestHistoryRecord.getStartDate(), unassignDate); + } else if (DateUtils.isDateInTheFuture(unassignDate)) { + final String errorMessage = "The Savings Officer Unassign date (" + unassignDate + ") cannot be in the future."; + throw new SavingsOfficerUnassignmentDateException("cannot.be.a.future.date", errorMessage, unassignDate); + } + } + + public MonetaryCurrency getCurrency() { + return this.currency; + } + + public void validateNewApplicationState(final String resourceName) { + // validateWithdrawalFeeDetails(); + // validateAnnualFeeDetails(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(resourceName + SavingsApiConstants.summitalAction); + + validateLockinDetails(baseDataValidator); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + final LocalDate submittedOn = getSubmittedOnDate(); + if (DateUtils.isDateInTheFuture(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(submittedOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + } + + if (this.client != null && this.client.isActivatedAfter(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.client.getActivationDate()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + } else if (this.group != null && this.group.isActivatedAfter(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.group.getActivationDate()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + } + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public Client getClient() { + return this.client; + } + + public BigDecimal getNominalAnnualInterestRate() { + return this.nominalAnnualInterestRate; + } + + public Integer getInterestCompoundingPeriodType() { + return this.interestCompoundingPeriodType; + } + + public Integer getInterestPostingPeriodType() { + return this.interestPostingPeriodType; + } + + public Integer getInterestCalculationType() { + return this.interestCalculationType; + } + + public BigDecimal getNominalAnnualInterestRateOverdraft() { + return this.nominalAnnualInterestRateOverdraft; + } + + public Map approveApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.approvalAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.APPROVED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + // only do below if status has changed in the 'approval' case + final LocalDate approvedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); + final String approvedOnDateChange = command.stringValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); + + this.approvedOnDate = approvedOn; + this.approvedBy = currentUser; + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.approvedOnDateParamName, approvedOnDateChange); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(approvedOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(approvedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_APPROVED, approvedOn); + + // FIXME - kw - support field officer history for savings accounts + // if (this.fieldOfficer != null) { + // final LoanOfficerAssignmentHistory loanOfficerAssignmentHistory = + // LoanOfficerAssignmentHistory.createNew(this, + // this.fieldOfficer, approvedOn); + // this.loanOfficerHistory.add(loanOfficerAssignmentHistory); + // } + if (this.savingsOfficer != null) { + final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, + this.savingsOfficer, approvedOn); + this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); + } + return actualChanges; + } + + public Map undoApplicationApproval() { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.undoApprovalAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + this.approvedOnDate = null; + this.approvedBy = null; + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + actualChanges.put(SavingsApiConstants.approvedOnDateParamName, ""); + + // FIXME - kw - support field officer history for savings accounts + // this.loanOfficerHistory.clear(); + + return actualChanges; + } + + public void undoTransaction(final Long transactionId) { + + SavingsAccountTransaction transactionToUndo = null; + for (final SavingsAccountTransaction transaction : this.transactions) { + if (transaction.isIdentifiedBy(transactionId)) { + transactionToUndo = transaction; + } + } + + if (transactionToUndo == null) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); + } + } + } + } + + public void undoSavingsTransaction(final Long transactionId) { + + SavingsAccountTransaction transactionToUndo = null; + for (final SavingsAccountTransaction transaction : this.savingsAccountTransactions) { + if (transaction.isIdentifiedBy(transactionId)) { + transactionToUndo = transaction; + } + } + + if (transactionToUndo == null) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } + } + } + } + + public void undoTransaction(final SavingsAccountTransaction transactionToUndo) { + + if (transactionToUndo.isReversed()) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionToUndo.getId()); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } + } + } + } + + private LocalDate findLatestAnnualFeeTransactionDueDate() { + LocalDate nextDueDate = null; + + LocalDate lastAnnualFeeTransactionDate = null; + for (final SavingsAccountTransaction transaction : retreiveOrderedNonInterestPostingTransactions()) { + if (transaction.isAnnualFeeAndNotReversed()) { + LocalDate transactionDate = transaction.getTransactionDate(); + if (lastAnnualFeeTransactionDate == null) { + lastAnnualFeeTransactionDate = transactionDate; + nextDueDate = lastAnnualFeeTransactionDate; + } + if (DateUtils.isAfter(transactionDate, lastAnnualFeeTransactionDate)) { + lastAnnualFeeTransactionDate = transactionDate; + nextDueDate = lastAnnualFeeTransactionDate; + } + } + } + return nextDueDate; + } + + public void validateAccountBalanceDoesNotBecomeNegativeMinimal(final BigDecimal transactionAmount, final boolean isException) { + // final List transactionsSortedByDate = retrieveListOfTransactions(); + Money runningBalance = this.summary.getAccountBalance(getCurrency()); + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + final BigDecimal withdrawalFee = null; + + // check last txn date + + // In overdraft cases, minRequiredBalance can be in violation after interest posting + // and should be checked after processing all transactions + if (!isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + + if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { + if (runningBalance.minus(this.getSavingsHoldAmount()).minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + } + + public Map rejectApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.rejectAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.REJECTED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + final LocalDate rejectedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); + final String rejectedOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); + + this.rejectedOnDate = rejectedOn; + this.rejectedBy = currentUser; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = rejectedOn; + this.closedBy = currentUser; + + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.rejectedOnDateParamName, rejectedOnAsString); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, rejectedOnAsString); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(rejectedOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(rejectedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(rejectedOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_REJECTED, rejectedOn); + + return actualChanges; + } + + public Map applicantWithdrawsFromApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.withdrawnByApplicantAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.WITHDRAWN_BY_APPLICANT.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + final LocalDate withdrawnOn = command.localDateValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); + final String withdrawnOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = withdrawnOn; + this.withdrawnBy = currentUser; + this.closedOnDate = withdrawnOn; + this.closedBy = currentUser; + + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.withdrawnOnDateParamName, withdrawnOnAsString); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, withdrawnOnAsString); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(withdrawnOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(withdrawnOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(withdrawnOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_WITHDRAWAL_BY_CUSTOMER, withdrawnOn); + + return actualChanges; + } + + public Map activate(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.activateAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate activationDate = command.localDateValueOfParameterNamed(SavingsApiConstants.activatedOnDateParamName); + + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.activatedOnDateParamName, activationDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + this.activatedOnDate = activationDate; + this.activatedBy = currentUser; + this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); + + /* + * if (annualFeeSettingsSet()) { updateToNextAnnualFeeDueDateFrom(getActivationLocalDate()); } + */ + if (this.client != null && this.client.isActivatedAfter(activationDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(this.client.getActivationDate()); + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (this.group != null && this.group.isActivatedAfter(activationDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(this.client.getActivationDate()); + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.group.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final LocalDate approvalDate = getApprovedOnDate(); + if (DateUtils.isBefore(activationDate, approvalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(approvalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.approval.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (DateUtils.isAfterBusinessDate(activationDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(activationDate) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_ACTIVATE, activationDate); + + return actualChanges; + } + + public void processAccountUponActivation(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + // update annual fee due date + for (SavingsAccountCharge charge : this.charges()) { + charge.updateToNextDueDateFrom(getActivationDate()); + } + + // auto pay the activation time charges (No need of checking the pivot date config) + this.payActivationCharges(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, false); + // TODO : AA add activation charges to actual changes list + } + + public Money activateWithBalance() { + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of any + // minimum required opening balance the product has configured. + return Money.zero(this.currency); + } + + public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + this.approvedOnDate = appliedonDate; + this.approvedBy = appliedBy; + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + this.activatedOnDate = appliedonDate; + this.activatedBy = appliedBy; + this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); + } + + private void payActivationCharges(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final boolean backdatedTxnsAllowedTill) { + boolean isSavingsChargeApplied = false; + boolean postReversals = false; + UUID refNo = UUID.randomUUID(); + for (SavingsAccountCharge savingsAccountCharge : this.charges()) { + if (savingsAccountCharge.isSavingsActivation()) { + isSavingsChargeApplied = true; + payCharge(savingsAccountCharge, savingsAccountCharge.getAmountOutstanding(getCurrency()), getActivationDate(), + backdatedTxnsAllowedTill, refNo.toString()); + } + } + + if (isSavingsChargeApplied) { + final MathContext mc = MathContext.DECIMAL64; + boolean isInterestTransfer = false; + LocalDate postInterestAsOnDate = null; + if (this.isBeforeLastPostingPeriod(getActivationDate(), backdatedTxnsAllowedTill)) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + this.postInterest(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, + postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); + } else { + final LocalDate today = DateUtils.getBusinessLocalDate(); + this.calculateInterestUsing(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); + } + } + } + + public Map close(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + + if (DateUtils.isBefore(closedDate, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("cannot.be.a.future.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (savingsAccountTransactions.size() > 0) { + final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (accountTransaction.isAfter(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.last.transaction.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + if (getAccountBalance().doubleValue() != 0) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.not.zero"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); + this.status = SavingsAccountStatusType.CLOSED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = closedDate; + this.closedBy = currentUser; + + return actualChanges; + } + + protected void validateActivityNotBeforeClientOrGroupTransferDate(final SavingsEvent event, final LocalDate activityDate) { + if (this.client != null) { + final LocalDate clientOfficeJoiningDate = this.client.getOfficeJoiningDate(); + if (DateUtils.isBefore(activityDate, clientOfficeJoiningDate)) { + throw new SavingsActivityPriorToClientTransferException(event.toString(), clientOfficeJoiningDate); + } + } + } + + private void validateAttemptToUndoTransferRelatedTransactions(final SavingsAccountTransaction savingsAccountTransaction) { + if (savingsAccountTransaction.isTransferRelatedTransaction()) { + throw new SavingsTransferTransactionsCannotBeUndoneException(savingsAccountTransaction.getId()); + } + } + + protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { + + LocalDate lockedInUntilLocalDate = null; + final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); + switch (lockinPeriodFrequencyType) { + case INVALID: + break; + case DAYS: + lockedInUntilLocalDate = activationLocalDate.plusDays(this.lockinPeriodFrequency); + break; + case WEEKS: + lockedInUntilLocalDate = activationLocalDate.plusWeeks(this.lockinPeriodFrequency); + break; + case MONTHS: + lockedInUntilLocalDate = activationLocalDate.plusMonths(this.lockinPeriodFrequency); + break; + case YEARS: + lockedInUntilLocalDate = activationLocalDate.plusYears(this.lockinPeriodFrequency); + break; + case WHOLE_TERM: + LOG.error("TODO Implement calculateDateAccountIsLockedUntil for WHOLE_TERM"); + break; + } + + return lockedInUntilLocalDate; + } + + public Group group() { + return this.group; + } + + public boolean isWithdrawalFeeApplicableForTransfer() { + return this.withdrawalFeeApplicableForTransfer; + } + + public void activateAccountBasedOnBalance() { + if (getStatus().isClosed() && !this.summary.getAccountBalance(getCurrency()).isZero()) { + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + } + } + + public LocalDate getClosedOnDate() { + return this.closedOnDate; + } + + public AppUser getClosedBy() { + return this.closedBy; + } + + public SavingsAccountSummary getSummary() { + return this.summary; + } + + public List getTransactions() { + return this.transactions; + } + + public void addTransaction(final SavingsAccountTransaction transaction) { + this.transactions.add(transaction); + } + + public void addTransactionToExisting(final SavingsAccountTransaction transaction) { + this.savingsAccountTransactions.add(transaction); + } + + public void setStatus(final Integer status) { + this.status = status; + } + + private Set associateChargesWithThisSavingsAccount(final Set savingsAccountCharges) { + for (final SavingsAccountCharge savingsAccountCharge : savingsAccountCharges) { + savingsAccountCharge.update(this); + } + return savingsAccountCharges; + } + + public boolean update(final Set newSavingsAccountCharges) { + if (newSavingsAccountCharges == null) { + return false; + } + + if (this.charges == null) { + this.charges = new HashSet<>(); + } + this.charges.clear(); + this.charges.addAll(associateChargesWithThisSavingsAccount(newSavingsAccountCharges)); + return true; + } + + public boolean hasCurrencyCodeOf(final String matchingCurrencyCode) { + if (this.currency == null) { + return false; + } + return this.currency.getCode().equalsIgnoreCase(matchingCurrencyCode); + } + + public void removeCharge(final SavingsAccountCharge charge) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isActive() || isApproved()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.charges.remove(charge); + } + + public void waiveCharge(final Long savingsAccountChargeId, final boolean backdatedTxnsAllowedTill) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final SavingsAccountCharge savingsAccountCharge = getCharge(savingsAccountChargeId); + + if (savingsAccountCharge.isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isWithdrawalFee()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.waiver.of.withdrawal.fee.not.supported"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // validate charge is not already paid or waived + if (savingsAccountCharge.isWaived()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } else if (savingsAccountCharge.isPaid()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // waive charge + final Money amountWaived = savingsAccountCharge.waive(getCurrency()); + handleWaiverChargeTransactions(savingsAccountCharge, amountWaived, backdatedTxnsAllowedTill); + } + + public void addCharge(final DateTimeFormatter formatter, final SavingsAccountCharge savingsAccountCharge, + final Charge chargeDefinition) { + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (!hasCurrencyCodeOf(chargeDefinition.getCurrencyCode())) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.currency.and.charge.currency.not.same"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final LocalDate chargeDueDate = savingsAccountCharge.getDueDate(); + + if (savingsAccountCharge.isOnSpecifiedDueDate()) { + if (DateUtils.isBefore(chargeDueDate, getActivationDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("before.activationDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } else if (DateUtils.isBefore(chargeDueDate, getSubmittedOnDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getSubmittedOnDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("before.submittedOnDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isSavingsActivation() && !(isSubmittedAndPendingApproval() || (isApproved() && isNotActive()))) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.valid.account.status.cannot.add.activation.time.charge"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + // Only one annual fee is supported per account + if (savingsAccountCharge.isAnnualFee()) { + if (this.isAnnualFeeExists()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("multiple.annual.fee.per.account.not.supported"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + } + + if (savingsAccountCharge.isAnnualFee() || savingsAccountCharge.isMonthlyFee() || savingsAccountCharge.isWeeklyFee()) { + // update due date + if (isActive()) { + savingsAccountCharge.updateToNextDueDateFrom(getActivationDate()); + } else if (isApproved()) { + savingsAccountCharge.updateToNextDueDateFrom(getApprovedOnDate()); + } + } + + // activation charge and withdrawal charges not required this validation + if (savingsAccountCharge.isOnSpecifiedDueDate()) { + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLY_CHARGE, chargeDueDate); + } + + // add new charge to savings account + this.charges.add(savingsAccountCharge); + + } + + private boolean isAnnualFeeExists() { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isAnnualFee()) { + return true; + } + } + return false; + } + + public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final BigDecimal amountPaid, + final LocalDate transactionDate, final DateTimeFormatter formatter, final boolean backdatedTxnsAllowedTill, + final String refNo) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (DateUtils.isBefore(transactionDate, getActivationDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.before.activationDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDate)) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(transactionDate.format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.is.futureDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (savingsAccountCharge.isSavingsActivation()) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("transaction.not.valid.cannot.pay.activation.time.charge.is.automated"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (savingsAccountCharge.isAnnualFee()) { + final LocalDate annualFeeDueDate = savingsAccountCharge.getDueDate(); + if (annualFeeDueDate == null) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.annualfee.settings"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDate, annualFeeDueDate)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.before.dueDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + LocalDate currentAnnualFeeNextDueDate = findLatestAnnualFeeTransactionDueDate(); + if (DateUtils.isEqual(currentAnnualFeeNextDueDate, transactionDate)) { + baseDataValidator.reset().parameter("dueDate").value(transactionDate.format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.exists.on.date"); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // validate charge is not already paid or waived + if (savingsAccountCharge.isWaived()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } else if (savingsAccountCharge.isPaid()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Money chargePaid = Money.of(currency, amountPaid); + if (!savingsAccountCharge.getAmountOutstanding(getCurrency()).isGreaterThanOrEqualTo(chargePaid)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.charge.amount.paid.in.access"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + return this.payCharge(savingsAccountCharge, chargePaid, transactionDate, backdatedTxnsAllowedTill, refNo); + } + + public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final Money amountPaid, + final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, String refNo) { + savingsAccountCharge.pay(getCurrency(), amountPaid); + return handlePayChargeTransactions(savingsAccountCharge, amountPaid, transactionDate, backdatedTxnsAllowedTill, refNo); + } + + private SavingsAccountTransaction handlePayChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, + final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, final String refNo) { + SavingsAccountTransaction chargeTransaction; + + if (savingsAccountCharge.isWithdrawalFee()) { + chargeTransaction = SavingsAccountTransaction.withdrawalFee(this, office(), transactionDate, transactionAmount, refNo); + } else if (savingsAccountCharge.isAnnualFee()) { + chargeTransaction = SavingsAccountTransaction.annualFee(this, office(), transactionDate, transactionAmount); + } else { + chargeTransaction = SavingsAccountTransaction.charge(this, office(), transactionDate, transactionAmount); + } + + handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); + return chargeTransaction; + } + + private void handleWaiverChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, + boolean backdatedTxnsAllowedTill) { + final SavingsAccountTransaction chargeTransaction = SavingsAccountTransaction.waiver(this, office(), + DateUtils.getBusinessLocalDate(), transactionAmount); + handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); + } + + private void handleChargeTransactions(final SavingsAccountCharge savingsAccountCharge, final SavingsAccountTransaction transaction, + final boolean backdatedTxnsAllowedTill) { + // Provide a link between transaction and savings charge for which + // amount is waived. + final SavingsAccountChargePaidBy chargePaidBy = SavingsAccountChargePaidBy.instance(transaction, savingsAccountCharge, + transaction.getAmount(this.currency).getAmount()); + transaction.getSavingsAccountChargesPaid().add(chargePaidBy); + if (backdatedTxnsAllowedTill) { + this.savingsAccountTransactions.add(transaction); + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } else { + this.transactions.add(transaction); + } + } + + private SavingsAccountCharge getCharge(final Long savingsAccountChargeId) { + SavingsAccountCharge charge = null; + for (final SavingsAccountCharge existingCharge : this.charges) { + if (existingCharge.getId().equals(savingsAccountChargeId)) { + charge = existingCharge; + break; + } + } + + if (charge == null) { + throw new SavingsAccountChargeNotFoundException(savingsAccountChargeId, getId()); + } + + return charge; + } + + public Set charges() { + return (this.charges == null) ? new HashSet() : this.charges; + } + + public void validateAccountValuesWithProduct() { + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (this.overdraftLimit != null && this.product.overdraftLimit() != null + && this.overdraftLimit.compareTo(this.product.overdraftLimit()) > 0) { + baseDataValidator.reset().parameter(SavingsApiConstants.overdraftLimitParamName).value(this.overdraftLimit) + .failWithCode("cannot.exceed.product.value"); + } + + validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public boolean allowOverdraft() { + return this.allowOverdraft; + } + + public LocalDate accountSubmittedOrActivationDate() { + return getActivationDate() == null ? getSubmittedOnDate() : getActivationDate(); + } + + protected boolean isTransferInterestToOtherAccount() { + return false; + } + + public boolean accountSubmittedAndActivationOnSameDate() { + return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate()); + + } + + public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidatorBuilder baseDataValidator) { + Map> postingtoCompoundMap = new HashMap<>(); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.DAILY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.MONTHLY, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.QUATERLY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.BIANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, + SavingsCompoundingInterestPeriodType.ANNUAL)); + + SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType + .fromInt(interestPostingPeriodType); + SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(interestCompoundingPeriodType); + + if (postingtoCompoundMap.get(savingsPostingInterestPeriodType) == null) { + baseDataValidator.failWithCodeNoParameterAddedToErrorCode("posting.period.type.is.less.than.compound.period.type", + savingsPostingInterestPeriodType.name(), savingsCompoundingInterestPeriodType.name()); + + } + } + + public AppUser getSubmittedBy() { + return this.submittedBy; + } + + public AppUser getApprovedBy() { + return this.approvedBy; + } + + public boolean allowDeposit() { + return true; + } + + public boolean allowWithdrawal() { + return true; + } + + public boolean allowModify() { + return true; + } + + public boolean isTransactionsAllowed() { + return isActive(); + } + + public boolean isTransactionAllowed(SavingsAccountTransactionType transactionType, LocalDate transactionDate) { + if (!isTransactionsAllowed()) { + return false; + } + + Client client = getClient(); + if (client != null && !client.isActive()) { + return false; + } + Group group = group(); + if (group != null && !group.isActive()) { + return false; + } + + if (transactionDate == null) { + return true; + } + if (DateUtils.isDateInTheFuture(transactionDate) || DateUtils.isBefore(transactionDate, getActivationDate())) { + return false; + } + if (transactionType.isCredit()) { + return true; + } + return !isAccountLocked(transactionDate); + } + + public BigDecimal minBalanceForInterestCalculation() { + return this.minBalanceForInterestCalculation; + } + + public void inactivateCharge(SavingsAccountCharge savingsAccountCharge, LocalDate inactivationOnDate) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + savingsAccountCharge.inactiavateCharge(inactivationOnDate); + } + + public SavingsAccountCharge getUpdatedChargeDetails(SavingsAccountCharge savingsAccountCharge) { + for (final SavingsAccountCharge charge : this.charges) { + if (charge.equals(savingsAccountCharge)) { + savingsAccountCharge = charge; + break; + } + } + return savingsAccountCharge; + } + + public String getAccountNumber() { + return this.accountNumber; + } + + private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { + // Minimum required balance is not enforced -- withdrawals (and every other balance check + // that goes through this) must never be blocked by a configured minimum balance. Overdraft + // headroom is a separate concept and still applies. + Money minReqBalance = Money.zero(currency); + if (this.allowOverdraft) { + minReqBalance = minReqBalance.minus(this.overdraftLimit); + } + return minReqBalance; + } + + public BigDecimal getOnHoldFunds() { + return this.onHoldFunds == null ? BigDecimal.ZERO : this.onHoldFunds; + } + + public void holdFunds(BigDecimal onHoldFunds) { + this.onHoldFunds = getOnHoldFunds().add(onHoldFunds); + } + + public void releaseFunds(BigDecimal onHoldFunds) { + this.onHoldFunds = getOnHoldFunds().subtract(onHoldFunds); + } + + public BigDecimal getWithdrawableBalance() { + return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) + .subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing()); + } + + public BigDecimal getWithdrawableBalanceWithLien() { + return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) + .subtract(this.getSavingsHoldAmount()); + } + + public BigDecimal getWithdrawableBalanceWithoutMinimumBalance() { + return getAccountBalance().subtract(this.getOnHoldFunds()).subtract(this.getSavingsHoldAmount()); + } + + public TaxGroup getTaxGroup() { + return this.taxGroup; + } + + public boolean withHoldTax() { + return this.withHoldTax; + } + + public void setWithHoldTax(boolean withHoldTax) { + this.withHoldTax = withHoldTax; + } + + protected boolean applyWithholdTaxForDepositAccounts(final LocalDate interestPostingUpToDate, boolean recalucateDailyBalance, + final boolean backdatedTxnsAllowedTill) { + final List withholdTransactions = findWithHoldTransactions(); + SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingUpToDate, withholdTransactions); + final BigDecimal totalInterestPosted = this.savingsAccountTransactionSummaryWrapper.calculateTotalInterestPosted(this.currency, + this.transactions); + if (withholdTransaction == null && this.withHoldTax()) { + boolean isWithholdTaxAdded = createWithHoldTransaction(totalInterestPosted, interestPostingUpToDate, backdatedTxnsAllowedTill); + recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; + } else { + boolean isWithholdTaxAdded = updateWithHoldTransaction(totalInterestPosted, withholdTransaction); + recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; + } + + return recalucateDailyBalance; + } + + public void setSubStatusInactive(final boolean backdatedTxnsAllowedTill) { + this.sub_status = SavingsAccountSubStatusEnum.INACTIVE.getValue(); + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isSavingsNoActivity() && charge.isActive()) { + charge.updateWithdralFeeAmount(this.getAccountBalance()); + UUID refNo = UUID.randomUUID(); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo.toString()); + } + } + boolean postReversals = false; + recalculateDailyBalances(Money.zero(this.currency), transactionDate, backdatedTxnsAllowedTill, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void recalculateBalancesAfterFacilityCharges(final LocalDate transactionDate) { + recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, false); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void setSubStatusDormant() { + this.sub_status = SavingsAccountSubStatusEnum.DORMANT.getValue(); + } + + public void escheat(AppUser appUser) { + this.status = SavingsAccountStatusType.CLOSED.getValue(); + this.sub_status = SavingsAccountSubStatusEnum.ESCHEAT.getValue(); + this.closedOnDate = DateUtils.getBusinessLocalDate(); + this.closedBy = appUser; + boolean postInterestAsOnDate = false; + boolean postReversals = false; + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + if (this.getSummary().getAccountBalance(this.getCurrency()).isGreaterThanZero()) { + SavingsAccountTransaction transaction = SavingsAccountTransaction.escheat(this, transactionDate, postInterestAsOnDate); + this.transactions.add(transaction); + } + recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void loadLazyCollections() { + transactions.size(); + charges.size(); + savingsOfficerHistory.size(); + if (group != null) { + Office dummyOffice = group.getOffice(); + } // Ensure lazy loading of group if set + } + + public void updateSavingsAccountSummary(final List transactions) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, transactions); + } + + public void updateReason(final String reasonForBlock) { + this.reasonForBlock = reasonForBlock; + } + + public Map block() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblock() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.blocked.state"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public Map blockCredits(Integer currentSubstatus) { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockCreditsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblockCredits() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockCreditsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!(SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus) + || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode("credits.are.not.blocked"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public Map blockDebits(Integer currentSubstatus) { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockDebitsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblockDebits() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockDebitsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus) + || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .failWithCodeNoParameterAddedToErrorCode("debits.are.not.blocked"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public SavingsAccountStatusType getStatus() { + return SavingsAccountStatusType.fromInt(status); + } + + public Integer getSubStatus() { + return this.sub_status; + } + + /** + * Applies the debit block that follows a lapsed periodic re-KYC. + * + *

+ * Separate from {@link #blockDebits(Integer)} because that path is an officer's deliberate act + * and raises a validation error when the account is not active or is already blocked. This one + * runs unattended over the whole book, where those are ordinary conditions rather than mistakes + * — an already-blocked account simply needs nothing doing — so it reports whether it acted + * instead of throwing. + * + * @return true if the sub-status was changed + */ + public boolean applyKycExpiryDebitBlock() { + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) { + return false; + } + final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + // A full block already stops debits, and a credit block plus a debit block is a full block. + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) { + return false; + } + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(current) ? SavingsAccountSubStatusEnum.BLOCK.getValue() + : SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); + return true; + } + + /** + * Applies the full block that follows a re-KYC left outstanding past the inoperative threshold. + * + *

+ * The credits RBI requires be let through — benefit transfers, interest, the bank's own loan + * recoveries — are not covered by this flag and are posted through the paths that bypass the + * sub-status check, so blocking here does not hold them up. + * + * @return true if the sub-status was changed + */ + public boolean applyKycExpiryFullBlock() { + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) { + return false; + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status))) { + return false; + } + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + return true; + } + + /** + * Lifts a block that a lapsed re-KYC put on, once the KYC has been renewed. + * + *

+ * Only the states this ladder itself sets are cleared. A block an officer applied by hand, or + * one carrying a dormancy or escheat state, is left exactly as it is: this job put none of + * those on and has no business taking them off. + * + * @return true if the sub-status was changed + */ + public boolean releaseKycExpiryBlock() { + final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + return true; + } + return false; + } + + public void validateForAccountBlock() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + throw new SavingsAccountBlockedException(this.getId()); + } + } + + public void validateForDebitBlock() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus)) { + throw new SavingsAccountDebitsBlockedException(this.getId()); + } + } + + public void validateForCreditBlock() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus)) { + throw new SavingsAccountCreditsBlockedException(this.getId()); + } + } + + public LocalDate retrieveLastTransactionDate() { + final List transactionsSortedByDate = retrieveListOfTransactions(); + SavingsAccountTransaction lastTransaction = null; + if (transactionsSortedByDate.size() > 0) { + lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); + } + LocalDate lastransactionDate = null; + if (lastTransaction != null) { + lastransactionDate = lastTransaction.getTransactionDate(); + } + return lastransactionDate; + } + + public LocalDate retrieveLastTransactionDateWithPivotConfig() { + final List transactionsSortedByDate = retrieveSortedTransactions(); + SavingsAccountTransaction lastTransaction = null; + if (transactionsSortedByDate.size() > 0) { + lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); + } + LocalDate lastransactionDate = null; + if (lastTransaction != null) { + lastransactionDate = lastTransaction.getTransactionDate(); + } + return lastransactionDate; + } + + /** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a + * {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather + * than a decision someone made about this specific account. */ + public static final String HOLD_CATEGORY_CASH = "CASH"; + public static final String HOLD_CATEGORY_CLEARING = "CLEARING"; + + public BigDecimal getSavingsHoldAmount() { + return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount; + } + + public BigDecimal getSavingsHoldAmountClearing() { + return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing; + } + + /** Defaults to the manual-lien bucket -- every call site written before the two-category split + * keeps behaving exactly as it did before. */ + public void holdAmount(BigDecimal amount) { + holdAmount(amount, HOLD_CATEGORY_CASH); + } + + public void holdAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); + } + } + + public void releaseOnHoldAmount(BigDecimal amount) { + releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH); + } + + public void releaseOnHoldAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); + } + } + + public AccountType getAccountType() { + return AccountType.fromInt(accountType); + } + + public Integer getAccountTypes() { + return accountType; + } + + public void setAccountType(Integer accountType) { + this.accountType = accountType; + } + + private boolean isOverdraft() { + return allowOverdraft; + } + + public Long getGroupId() { + return this.groupId(); + } + + public Integer getInterestCalculationDaysInYearType() { + return this.interestCalculationDaysInYearType; + } + + public BigDecimal getMinRequiredOpeningBalance() { + return this.minRequiredOpeningBalance; + } + + public Integer getLockinPeriodFrequency() { + return this.lockinPeriodFrequency; + } + + public Integer getLockinPeriodFrequencyType() { + return this.lockinPeriodFrequencyType; + } + + public boolean isWithdrawalFeeForTransfer() { + return this.withdrawalFeeApplicableForTransfer; + } + + public boolean isAllowOverdraft() { + return this.allowOverdraft; + } + + public BigDecimal getOverdraftLimit() { + return this.overdraftLimit; + } + + public BigDecimal getMinOverdraftForInterestCalculation() { + return this.minOverdraftForInterestCalculation; + } + + public BigDecimal getMinRequiredBalance() { + return this.minRequiredBalance; + } + + public boolean isEnforceMinRequiredBalance() { + return this.enforceMinRequiredBalance; + } + + public BigDecimal getMaxAllowedLienLimit() { + return this.maxAllowedLienLimit; + } + + public boolean isLienAllowed() { + return this.lienAllowed; + } + + public BigDecimal getMinBalanceForInterestCalculation() { + return this.minBalanceForInterestCalculation; + } + + public int getVersion() { + return this.version; + } + + public boolean isWithHoldTax() { + return this.withHoldTax; + } + + public void setAccruedTillDate(LocalDate accruedTillDate) { + this.accruedTillDate = accruedTillDate; + } + + public LocalDate getLastClosedBusinessDate() { + return this.lastClosedBusinessDate; + } + + public void setLastClosedBusinessDate(LocalDate lastClosedBusinessDate) { + this.lastClosedBusinessDate = lastClosedBusinessDate; + } + + public List toSavingsAccountTransactionDetailsForPostingPeriodList( + List transactions) { + return transactions.stream() + .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) + .toList(); + } + + public void accrualsForSavingsReverse(SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill) { + List accountTransactionsSorted = null; + + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { + boolean typeTransaccionValidation = transaction.getTransactionType() == SavingsAccountTransactionType.ACCRUAL; + if (typeTransaccionValidation && (transaction.getDateOf().isAfter(transactionDTO.getTransactionDate()) + || transaction.getDateOf().isEqual(transactionDTO.getTransactionDate()))) { + transaction.reverse(); + } + } + } + + public List toSavingsAccountTransactionDetailsForPostingPeriodList() { + return retreiveOrderedNonInterestPostingTransactions().stream() + .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) + .toList(); + } + + @Override + public DepositAccountType depositAccountType() { + return DepositAccountType.fromInt(100); + } + + // ─── CBS extended fields ─────────────────────────────────────────────────── + + @Column(name = "cbs_account_type", length = 100) + private String cbsAccountType; + + @Column(name = "customer_segment", length = 100) + private String customerSegment; + + @Column(name = "account_status", length = 50) + private String accountStatus; + + @Column(name = "mode_of_operation", length = 50) + private String modeOfOperation; + + @Column(name = "special_category", length = 100) + private String specialCategory; + + @Column(name = "weaker_section", length = 100) + private String weakerSection; + + @Column(name = "guardian_name", length = 200) + private String guardianName; + + @Column(name = "guardian_cif_number", length = 50) + private String guardianCifNumber; + + @Column(name = "guardian_relationship", length = 100) + private String guardianRelationship; + + @Column(name = "minor_date_of_birth") + private LocalDate minorDateOfBirth; + + @Column(name = "guardian_client_id") + private Long guardianClientId; + + @Column(name = "introducer_client_id") + private Long introducerClientId; + + @Column(name = "introducer_name", length = 200) + private String introducerName; + + @Column(name = "introducer_cif_number", length = 50) + private String introducerCifNumber; + + @Column(name = "nominee_data", columnDefinition = "TEXT") + private String nomineeData; + + @Column(name = "linked_account_id") + private Long linkedAccountId; + + @Column(name = "linked_account_type", length = 20) + private String linkedAccountType; + + @Column(name = "linked_account_no", length = 50) + private String linkedAccountNo; + + @Column(name = "introducer_required", nullable = false, columnDefinition = "boolean default false") + private Boolean introducerRequired = false; + + @Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false") + private Boolean shareLinkingEnabled = false; + + /** + * Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain + * class (it lives in fineract-provider, which depends on fineract-savings, not the other way + * round). The write side resolves and validates this id against m_share_account directly; this + * column just carries it back to the share account's own {@code savings_account_id}, mirroring + * the link from both ends. + */ + @Column(name = "share_account_id") + private Long shareAccountId; + + @Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false") + private Boolean nominationFacility = false; + + @Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false") + private Boolean autoDebitFacility = false; + + @Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false") + private boolean isSeniorCitizen = false; + + @Column(name = "senior_citizen_interest_rate", scale = 6, precision = 19, nullable = true) + private BigDecimal seniorCitizenInterestRate; + + @Column(name = "terminate_true", nullable = false, columnDefinition = "boolean default false") + private boolean terminateTrue = false; + + public boolean isTerminateTrue() { return terminateTrue; } + public void setTerminateTrue(boolean terminateTrue) { this.terminateTrue = terminateTrue; } + + public Map terminate() { + final Map actualChanges = new LinkedHashMap<>(); + this.terminateTrue = true; + this.status = SavingsAccountStatusType.TERMINATED.getValue(); + actualChanges.put("terminateTrue", true); + actualChanges.put("status", SavingsAccountStatusType.TERMINATED.getValue()); + return actualChanges; + } + + + @jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private SavingsAccountFacilityConfig facilityConfig; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private List jointHolders = new ArrayList<>(); + + public String getCbsAccountType() { return cbsAccountType; } + public void setCbsAccountType(String v) { this.cbsAccountType = v; } + + public String getCustomerSegment() { return customerSegment; } + public void setCustomerSegment(String v) { this.customerSegment = v; } + + public String getAccountStatus() { return accountStatus; } + public void setAccountStatus(String v) { this.accountStatus = v; } + + public String getModeOfOperation() { return modeOfOperation; } + public void setModeOfOperation(String v) { this.modeOfOperation = v; } + + public String getSpecialCategory() { return specialCategory; } + public void setSpecialCategory(String v) { this.specialCategory = v; } + + public String getWeakerSection() { return weakerSection; } + public void setWeakerSection(String v) { this.weakerSection = v; } + + public String getGuardianName() { return guardianName; } + public void setGuardianName(String v) { this.guardianName = v; } + + public String getGuardianCifNumber() { return guardianCifNumber; } + public void setGuardianCifNumber(String v) { this.guardianCifNumber = v; } + + public String getGuardianRelationship() { return guardianRelationship; } + public void setGuardianRelationship(String v) { this.guardianRelationship = v; } + + public LocalDate getMinorDateOfBirth() { return minorDateOfBirth; } + public void setMinorDateOfBirth(LocalDate v) { this.minorDateOfBirth = v; } + + public Long getGuardianClientId() { return guardianClientId; } + public void setGuardianClientId(Long v) { this.guardianClientId = v; } + + public Long getIntroducerClientId() { return introducerClientId; } + public void setIntroducerClientId(Long v) { this.introducerClientId = v; } + + public String getIntroducerName() { return introducerName; } + public void setIntroducerName(String v) { this.introducerName = v; } + + public String getIntroducerCifNumber() { return introducerCifNumber; } + public void setIntroducerCifNumber(String v) { this.introducerCifNumber = v; } + + public String getNomineeData() { return nomineeData; } + public void setNomineeData(String v) { this.nomineeData = v; } + + public Long getLinkedAccountId() { return linkedAccountId; } + public void setLinkedAccountId(Long v) { this.linkedAccountId = v; } + + public String getLinkedAccountType() { return linkedAccountType; } + public void setLinkedAccountType(String v) { this.linkedAccountType = v; } + + public String getLinkedAccountNo() { return linkedAccountNo; } + public void setLinkedAccountNo(String v) { this.linkedAccountNo = v; } + + public SavingsAccountFacilityConfig getFacilityConfig() { return facilityConfig; } + public void setFacilityConfig(SavingsAccountFacilityConfig v) { this.facilityConfig = v; } + + public List getJointHolders() { return jointHolders; } + public void setJointHolders(List v) { this.jointHolders = v; } + + public Boolean getNominationFacility() { return nominationFacility; } + public void setNominationFacility(Boolean v) { this.nominationFacility = v; } + + public Boolean getAutoDebitFacility() { return autoDebitFacility; } + public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } + + public Boolean getIntroducerRequired() { return introducerRequired; } + public void setIntroducerRequired(Boolean v) { this.introducerRequired = v; } + + public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; } + public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; } + + public Long getShareAccountId() { return shareAccountId; } + public void setShareAccountId(Long v) { this.shareAccountId = v; } + + public boolean isSeniorCitizen() { return isSeniorCitizen; } + public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; } + + public BigDecimal getSeniorCitizenInterestRate() { return seniorCitizenInterestRate; } + public void setSeniorCitizenInterestRate(BigDecimal v) { this.seniorCitizenInterestRate = v; } + + public void updateNominalAnnualInterestRate(BigDecimal rate) { + if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { + this.nominalAnnualInterestRate = rate; + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java index dc3b0bfbd..a5dffae56 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java @@ -75,9 +75,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom { @Column(name = "inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java index 62387acc1..df6a4fc9c 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java @@ -371,8 +371,6 @@ public class SavingsProductAssembler { facilityConfigJson.get("rtgsFacility").getAsBoolean() : false); config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false); - config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? - facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false); config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false); config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java index 20e5180d7..dfb95deab 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java @@ -75,9 +75,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom + * Mappings are seeded INACTIVE on purpose. Activating a mapping is what actually starts charging + * the customer, so that stays an explicit admin decision made through that screen — + * FacilityChargeAutoApplyService treats an inactive mapping exactly like an unmapped one and blocks + * the facility from being enabled until an admin flips Active. Seeding the charge only removes the + * tedium of picking the obvious one; it does not pre-approve any billing. + *

+ * Every step is guarded (charges created only when missing, mappings linked only while still + * unmapped), so an admin's own choice of charge or amount is never overwritten and the seeder is + * safe to run repeatedly and against existing databases. */ @Service @RequiredArgsConstructor @Slf4j -public class FacilityChargeSeederService { +@DependsOn({ "tenantDetailsService", "tenantDatabaseUpgradeService" }) +public class FacilityChargeSeederService implements InitializingBean { private final JdbcTemplate jdbcTemplate; + private final TenantDetailsService tenantDetailsService; // facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo] // chargeTimeType: 3=SAVINGS_ACTIVATION, 6=ANNUAL_FEE, 7=MONTHLY_FEE @@ -63,13 +76,50 @@ public class FacilityChargeSeederService { FACILITY_DEFAULTS.put("NEFT_FACILITY", new Object[]{"NEFT Facility Charge", 5.00, 3, 2}); FACILITY_DEFAULTS.put("RTGS_FACILITY", new Object[]{"RTGS Facility Charge", 25.00, 3, 2}); FACILITY_DEFAULTS.put("INTER_BANK_TOTAL_CAP", new Object[]{"Inter-Bank Cap Charge", 0.00, 3, 2}); - FACILITY_DEFAULTS.put("WITHDRAWAL_FACILITY", new Object[]{"Withdrawal Facility Charge", 0.00, 3, 2}); FACILITY_DEFAULTS.put("DEPOSIT_FACILITY_KYC", new Object[]{"Deposit KYC Charge", 0.00, 3, 2}); FACILITY_DEFAULTS.put("VIRTUAL_DEBIT_CARD", new Object[]{"Virtual Debit Card Charge", 199.00, 3, 2}); } - @PostConstruct - @Transactional + /** + * Runs once per tenant after the tenant datasource and Liquibase migrations are ready. + *

+ * This deliberately does NOT use {@code @PostConstruct}: that fires while the bean graph is still + * being built, before Fineract's per-tenant routing datasource is available, so every tenant table + * lookup failed with "bad SQL grammar" and the seeder silently skipped on every startup. Seeding + * has to happen after {@code tenantDatabaseUpgradeService} has migrated the schema, with the tenant + * bound to the thread so the routing datasource resolves to that tenant's database. + */ + @Override + public void afterPropertiesSet() { + final List tenants = tenantDetailsService.findAllTenants(); + if (tenants == null || tenants.isEmpty()) { + log.warn("FacilityChargeSeeder: no tenants found, skipping."); + return; + } + + final FineractPlatformTenant originalTenant = ThreadLocalContextUtil.getTenant(); + try { + for (final FineractPlatformTenant tenant : tenants) { + try { + ThreadLocalContextUtil.setTenant(tenant); + seedFacilityCharges(); + } catch (Exception e) { + // Seeding defaults must never take the platform down for one bad tenant. + log.warn("FacilityChargeSeeder: skipped for tenant '{}' — {}", tenant.getTenantIdentifier(), + e.getMessage(), e); + } + } + } finally { + ThreadLocalContextUtil.setTenant(originalTenant); + } + } + + /** + * Not {@code @Transactional}: this is invoked directly from {@link #afterPropertiesSet()} on the same + * bean, so Spring's proxy would be bypassed and the annotation would be a false promise. It needs no + * transaction anyway — every statement is individually guarded and idempotent, so a partial run simply + * completes on the next startup. + */ public void seedFacilityCharges() { try { for (Map.Entry entry : FACILITY_DEFAULTS.entrySet()) { @@ -107,8 +157,7 @@ public class FacilityChargeSeederService { } // 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every - // facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge - // for it via the Facility Charge Mappings screen before it can be enabled on an account. + // facility type. Inserted inactive and unmapped; step 3 below fills in the charge. List existingMapping = jdbcTemplate.queryForList( "SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType); @@ -121,11 +170,37 @@ public class FacilityChargeSeederService { log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType); } + + // 3. Point each still-unmapped facility at its own default charge (ATM_WITHDRAWAL -> + // "ATM Withdrawal Charge", ...) so a fresh install shows the correct charge on every row + // instead of a dash, on any machine, without an admin re-picking all 15 by hand. + // + // is_active is deliberately left false: activating the mapping is what actually starts + // billing the customer, and that must stay an explicit admin decision made through the + // Facility Charge Mappings screen (FacilityChargeAutoApplyService.requireMappedCharge + // treats an inactive mapping exactly like an unmapped one, so nothing is charged until + // an admin flips Active). This only removes the tedium of choosing the obvious charge, + // not the approval step. + // + // Scoped to charge_id IS NULL so a mapping or amount an admin has already set is never + // overwritten -- that makes this safe to re-run on every startup and on existing databases. + final int linked = jdbcTemplate.update( + "UPDATE m_facility_charge_mapping SET charge_id = " + + "(SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id LIMIT 1) " + + "WHERE facility_type = ? AND charge_id IS NULL", + chargeName, facilityType); + + if (linked > 0) { + log.info("FacilityChargeSeeder: mapped facility '{}' to charge '{}' (left inactive" + + " -- an admin must activate it before it applies)", facilityType, chargeName); + } } log.info("FacilityChargeSeeder: seeding complete."); } catch (Exception e) { - log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage()); + // Log the cause, not just the message: this swallowed a startup-ordering failure silently + // for a long time because the underlying SQLException never reached the log. + log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage(), e); } } } diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index d2b0790a5..95f04b653 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -1,57 +1,58 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml index c25266bbf..0d8343c97 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml @@ -188,4 +188,21 @@ ); + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'SavingsProductAccountType', TRUE + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductAccountType'); + + INSERT INTO m_code_value (code_id, code_value, order_position, code_description, is_active, is_mandatory) + SELECT c.id, 'Normal Saving Account', 4, 'Normal Saving Account', TRUE, FALSE + FROM m_code c + WHERE c.code_name = 'SavingsProductAccountType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = 'Normal Saving Account' + ); + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml new file mode 100644 index 000000000..702128aef --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + DELETE FROM m_facility_charge_mapping WHERE facility_type = 'WITHDRAWAL_FACILITY'; + + + + + + + + + + DELETE FROM m_savings_account_charge + WHERE charge_id IN (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge') + AND COALESCE(is_paid_derived, false) = false + AND COALESCE(waived, false) = false; + + + + + + + SELECT COUNT(*) FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = false + + + UPDATE m_charge SET is_active = false, is_deleted = true + WHERE name = 'Withdrawal Facility Charge'; + + + + + + + + + + + + + UPDATE m_savings_account_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true; + + + + + + + + + + + + UPDATE m_savings_product_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true; + + + + From a83806062e3495cffea1cbd05e2a0089f79dad01 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 7 Sep 2026 18:36:20 +0530 Subject: [PATCH 33/55] feat(security): add cross-tenant API key auth for third-party callers Endpoints that need to be reachable by a third-party integration without a Fineract username/password used to have no supported way to do that other than the heavyweight IMPS/NPCI pattern (RSA-signed JWT envelopes, built for one specific bank-switch protocol). This adds a general-purpose one: mint an X-Api-Key credential, hand it to the partner, done. New table m_api_client lives in the tenant-STORE database (fineract_tenants), not any one bank's own tenant database. That is deliberate: a key identifies a CALLER, independent of which bank's data a given request asks for, so one minted key keeps working against every tenant that exists now and every one provisioned later -- a per-tenant copy would need a manual re-insert on every new bank and a row per tenant per key, which is exactly what storing it once, centrally, avoids. Fineract-Platform-TenantId keeps doing what it always did: routes the rest of a request to the right bank's database. This table only decides who is allowed to make the request at all. ApiClientCredentialService.authenticate() is what an endpoint calls in place of PlatformSecurityContext#authenticatedUser() when it sees an X-Api-Key header. Security properties: - Only a SHA-256 hash of the key is ever stored -- same reason Fineract never stores a user's plaintext password. The raw key is returned exactly once, at creation (POST /v1/apiclients), and cannot be retrieved again. - Every failure path (key doesn't exist, is deactivated, caller's IP isn't on its allowlist) raises the identical 401 message, so a probing caller can't use the response to tell a wrong key apart from a valid-but-blocked one. - allowedIps is a plain comma-separated exact-match list, adequate for a small set of known partner integrations, not CIDR ranges. ApiClientAdminApiResource (create/list/deactivate/reactivate a key) is itself gated by an ordinary Fineract login restricted to a super user (ALL_FUNCTIONS) -- issuing a credential that skips login is not something to hand out casually. SecurityConfig gets one new permitAll() matcher, narrowly scoped to GET /api/*/clientaccounts/** -- the same posture this fork already uses for the IMPS and NPCI receivers: the outer Spring Security gate stays open for one specific path, and the resource itself is what actually authenticates the caller. A normal logged-in SPA user hitting that same path is unaffected -- Basic Auth still runs for any request that carries an Authorization header, permitAll only stops an anonymous one from being rejected before the handler gets a chance to check for a key instead. --- .../core/config/SecurityConfig.java | 9 + .../apiclient/ApiClientAdminApiResource.java | 109 ++++++++ .../apiclient/ApiClientCredentialService.java | 243 ++++++++++++++++++ .../tenant-store/changelog-tenant-store.xml | 1 + .../parts/0011_create_api_client.xml | 59 +++++ 5 files changed, 421 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index c740cf276..7085dc8d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -163,6 +163,15 @@ public class SecurityConfig { // packages here without a Fineract Bearer token; security is enforced by the // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll() + // Client account summary, third-party callers — a partner integration calls this + // with an X-Api-Key header instead of a Fineract username/password (issued via + // POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the + // outer gate stays open for this one narrow GET path, and + // ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates + // the caller (ApiClientCredentialService checks the key's hash, active flag and + // IP allowlist) — a request with no key and no Fineract login still gets nothing, + // it just fails inside the handler instead of at this filter. + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll() // Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed, // no Fineract auth required. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll() diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java new file mode 100644 index 000000000..0c469621a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java @@ -0,0 +1,109 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.security.apiclient; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +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.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates. + * This resource itself requires a normal Fineract login, same as everything else -- ordinary + * username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted + * key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that + * key in the first place is not something to hand to just anyone. + */ +@Path("/v1/apiclients") +@Component +@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.") +@RequiredArgsConstructor +public class ApiClientAdminApiResource { + + private final PlatformSecurityContext context; + private final ApiClientCredentialService credentialService; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public String create(final String body) { + final AppUser user = requireSuperUser(); + final JsonElement root = fromJsonHelper.parse(body); + final String clientName = fromJsonHelper.extractStringNamed("clientName", root); + final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root); + + final Map result = credentialService.createClient(clientName, allowedIps, user.getId()); + return gson.toJson(result); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list() { + requireSuperUser(); + final List> clients = credentialService.listClients(); + return gson.toJson(clients); + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public String deactivate(@PathParam("id") final Long id) { + requireSuperUser(); + credentialService.setActive(id, false); + return gson.toJson(Map.of("resourceId", id, "isActive", false)); + } + + @POST + @Path("/{id}/reactivate") + @Produces(MediaType.APPLICATION_JSON) + public String reactivate(@PathParam("id") final Long id) { + requireSuperUser(); + credentialService.setActive(id, true); + return gson.toJson(Map.of("resourceId", id, "isActive", true)); + } + + private AppUser requireSuperUser() { + final AppUser user = context.authenticatedUser(); + if (!user.hasAnyPermission("ALL_FUNCTIONS")) { + throw new WebApplicationException(Response.status(403) + .entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON) + .build()); + } + return user; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java new file mode 100644 index 000000000..f4b6d4bf3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java @@ -0,0 +1,243 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.security.apiclient; + +import jakarta.annotation.PostConstruct; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user + * login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this + * fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at + * the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate + * stays open for the specific path, and this class is the thing that actually decides "yes" or "no". + * + * {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via + * the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean + * {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary + * tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not + * an oversight: a key is a caller's identity, independent of which bank's data a given request is asking + * for, so one minted key must keep working against every tenant that exists now AND every one + * provisioned later. A copy of this table inside each bank's own tenant database would need a manual + * re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing + * it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes + * the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to + * the right bank's database) -- this class only ever touches the one central table. + * + * Security properties, deliberately: + *

    + *
  • Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked + * database backup can never be turned back into a usable credential -- the same reason Fineract itself + * never stores a user's plaintext password.
  • + *
  • The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is + * not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and + * deactivate the old.
  • + *
  • {@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key + * doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker + * cannot use the response to tell a wrong key apart from a valid-but-blocked one.
  • + *
  • The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a + * small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave + * {@code allowedIps} null for that key rather than trying to enumerate every address.
  • + *
+ */ +@Component +@RequiredArgsConstructor +public class ApiClientCredentialService { + + private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key."; + // Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a + // session token, appropriate for a credential that never expires on its own. + private static final int KEY_ENTROPY_BYTES = 32; + + @Qualifier("hikariTenantDataSource") + private final DataSource tenantStoreDataSource; + private final SecureRandom secureRandom = new SecureRandom(); + + // Built once from the qualified DataSource in @PostConstruct rather than injected directly as a + // JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields, + // and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is. + private JdbcTemplate tenantStoreJdbc; + + @PostConstruct + void init() { + this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource); + } + + /** + * Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's + * display name (for the caller to log/attribute the request to) and records this call -- including + * which tenant it was made against -- as the key's most recent use. On any failure, throws a clean + * 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP. + */ + public String authenticate(final String rawApiKey, final String callerIp) { + if (StringUtils.isBlank(rawApiKey)) { + throw unauthorized(); + } + final String hash = sha256Hex(rawApiKey); + final List> rows = tenantStoreJdbc.queryForList(""" + SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ? + """, hash); + if (rows.isEmpty()) { + throw unauthorized(); + } + final Map row = rows.get(0); + if (!Boolean.TRUE.equals(row.get("is_active"))) { + throw unauthorized(); + } + final String allowedIps = (String) row.get("allowed_ips"); + if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) { + throw unauthorized(); + } + + final Long id = ((Number) row.get("id")).longValue(); + tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?", + java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id); + + return (String) row.get("client_name"); + } + + private boolean isIpAllowed(final String allowedIps, final String callerIp) { + if (StringUtils.isBlank(callerIp)) { + return false; + } + return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp)); + } + + /** + * Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value + * is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key + * authenticates against every tenant, regardless of which one the admin minting it happened to be + * logged into. + */ + public Map createClient(final String clientName, final String allowedIps, final Long createdByUserId) { + if (StringUtils.isBlank(clientName)) { + throw badRequest("clientName is required."); + } + final String rawKey = generateRawKey(); + final String hash = sha256Hex(rawKey); + final String prefix = rawKey.substring(0, Math.min(12, rawKey.length())); + + final Long id = tenantStoreJdbc.queryForObject(""" + INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant) + VALUES (?,?,?,?,?,?) + RETURNING id + """, Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId, + currentTenantId()); + + final Map result = new LinkedHashMap<>(); + result.put("id", id); + result.put("clientName", clientName); + result.put("apiKey", rawKey); + result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again."); + return result; + } + + public List> listClients() { + return tenantStoreJdbc.queryForList(""" + SELECT id, client_name, api_key_prefix, is_active, allowed_ips, + created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant + FROM m_api_client ORDER BY created_date DESC + """); + } + + public void setActive(final Long id, final boolean active) { + final int updated = tenantStoreJdbc.update( + "UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active, + active ? null : java.sql.Timestamp.from(Instant.now()), id); + if (updated == 0) { + throw notFound("No API client with id " + id); + } + } + + private String currentTenantId() { + try { + return ThreadLocalContextUtil.getTenant().getTenantIdentifier(); + } catch (final RuntimeException e) { + // Defensive only: every request through SecurityConfig's matcher chain has a tenant + // resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this + // should be unreachable in practice. + return null; + } + } + + private String generateRawKey() { + final byte[] bytes = new byte[KEY_ENTROPY_BYTES]; + secureRandom.nextBytes(bytes); + // URL-safe, no padding: safe to put directly in a header value with no further encoding. + return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String sha256Hex(final String raw) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(hashBytes.length * 2); + for (final byte b : hashBytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (final NoSuchAlgorithmException e) { + // SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports -- + // this can only mean a broken JVM install, not a recoverable runtime condition. + throw new IllegalStateException("SHA-256 is unavailable in this JVM", e); + } + } + + private WebApplicationException unauthorized() { + // Naming the tenant the caller sent is safe here -- it is the header value they themselves + // put on the request, not something this check reveals to them -- and it turns "why did my + // key fail" from a guess into something checkable: was this call even reaching the bank the + // caller thought it was? + final String tenantId = currentTenantId(); + final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE; + return jsonError(401, message); + } + + private WebApplicationException badRequest(final String message) { + return jsonError(400, message); + } + + private WebApplicationException notFound(final String message) { + return jsonError(404, message); + } + + private WebApplicationException jsonError(final int status, final String message) { + final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}"; + return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index de38521da..852255252 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -31,4 +31,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml new file mode 100644 index 000000000..a63cae5f0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 317a308d8cd00276d77c849f59c64bcf9c7735ee Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 7 Sep 2026 18:36:43 +0530 Subject: [PATCH 34/55] feat(client): add fast client-accounts lookup by client number or mobile number GET /v1/clientaccounts/{identifier} -- a single client's full deposit + share picture (Savings, Fixed Deposit, Recurring Deposit, Current, Share), looked up by client number or mobile number rather than the internal client id, so a teller with only the slip in front of them (or a third-party caller with only a mobile number) needs no separate client-search step first. Meant to answer "is there a problem here" (e.g. a share purchase that would exceed a product's issuable shares) BEFORE a manager approves something, not after. Deliberately its own lightweight resource rather than a reuse of the existing GET /clients/{clientId}/accounts (AccountSummaryCollectionData): that endpoint's row mappers each carry six LEFT JOINs to m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this caller doesn't need. One UNION ALL query (SB/FD/RD from m_savings_account split by deposit_type_enum, CD from its own m_current_account table, Share from m_share_account), each leg filtering on an indexed client_id -- fewer joins and one round trip is what actually makes this fast, not a different query engine. Identifier resolution tries m_client.account_no first (what a teller has), then mobile_no (what a third-party caller more often has) -- both unique indexed columns, so neither path costs more than a single index lookup. Reachable two ways: an ordinary logged-in Fineract user exactly as with any other endpoint, or a third-party caller via the new X-Api-Key mechanism (see the ApiClientCredentialService commit) -- one key now valid across every tenant, so the response echoes back tenantId/tenantName and the identifier that was searched, and a not-found error names the bank actually searched. Without that, "no client found" from the wrong bank (a stale or mistyped Fineract-Platform-TenantId header) was indistinguishable from a genuinely unknown client. Every date field is serialized as a plain ISO string, never a raw java.time.LocalDate: this Gson instance has no java.time adapter registered, and reflectively touching LocalDate's private fields is blocked outright by the JVM module system (java.base does not open java.time to an unnamed module) -- passing one through untouched turns the whole response into a 500. --- .../api/ClientAllAccountsApiResource.java | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java new file mode 100644 index 000000000..6391f8268 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java @@ -0,0 +1,330 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; +import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData; +import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +/** + * A single client's full deposit + share picture, looked up by client number OR mobile number (the + * path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather + * than the internal client id -- these are what a counter clerk has on the slip, or what a third-party + * caller was given, respectively, so no separate client-search step is needed before this call. Built + * so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only + * queries, on the theory that this is exactly the kind of check a teller or manager runs right before + * approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here" + * question this endpoint answers has to come back before that decision is made, not after. + * + * Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData} + * (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to + * m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this + * caller doesn't need -- account number, product, status and the current balance/share value is the + * whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query + * engine. + * + * Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as + * they do every other endpoint; a third-party integration that should never see a Fineract username or + * password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted + * via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in + * {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the + * IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the + * handler itself is what actually authenticates the caller. + */ +@Path("/v1/clientaccounts") +@Component +@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.") +@RequiredArgsConstructor +public class ClientAllAccountsApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final ApiClientCredentialService apiClientCredentialService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local + // rather than depending on that filter bean: it's only conditionally registered + // (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether + // that unrelated feature toggle is on. + private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; + + // One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum), + // CD (m_current_account, its own table -- current accounts were never folded into + // m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same + // split), and Share (m_share_account). Every leg filters on client_id, which is indexed on + // all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an + // index-only lookup per leg, not a scan. + private static final String ACCOUNTS_SQL = """ + SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id, + sa.status_enum AS status_enum, sa.currency_code, sa.product_id, + p.name AS product_name, p.short_name AS short_product_name, + sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold, + NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price, + sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date + FROM m_savings_account sa + JOIN m_savings_product p ON p.id = sa.product_id + WHERE sa.client_id = ? + + UNION ALL + + SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id, + ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name, + ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0), + NULL::bigint, NULL::bigint, NULL::numeric(10,2), + ca.submittedon_date, ca.activatedon_date + FROM m_current_account ca + JOIN m_current_product cp ON cp.id = ca.product_id + WHERE ca.client_id = ? + + UNION ALL + + SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id, + shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name, + NULL::numeric(19,6), NULL::numeric(19,6), + shr.total_approved_shares, shr.total_pending_shares, sp.unit_price, + shr.submitted_date, shr.activated_date + FROM m_share_account shr + JOIN m_share_product sp ON sp.id = shr.product_id + WHERE shr.client_id = ? + """; + + @GET + @Path("/{identifier}") + @Produces(MediaType.APPLICATION_JSON) + public String retrieveByIdentifier(@PathParam("identifier") final String identifier, + @HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + // Dual-mode: a header present means a third-party caller, checked against m_api_client and + // never touching Fineract's own login. No header means an ordinary logged-in user -- exactly + // the same authenticatedUser() gate every other resource in this codebase uses. The two are + // mutually exclusive on purpose: a request carrying an API key is never also allowed to fall + // back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a + // legitimate session's cookies/headers. + if (StringUtils.isNotBlank(apiKey)) { + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } else { + context.authenticatedUser(); + } + + final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant(); + final Map client = resolveClient(identifier, tenant); + final Long clientId = ((Number) client.get("id")).longValue(); + + final List> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId); + + final Map totalsByCurrency = new LinkedHashMap<>(); + final Map countByType = new LinkedHashMap<>(); + for (final Map row : rows) { + final String accountType = (String) row.get("accountType"); + countByType.merge(accountType, 1, Integer::sum); + + final Object valueObj = row.get("currentValue"); + if (valueObj instanceof BigDecimal value) { + final String currency = (String) row.get("currencyCode"); + final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO); + totalsByCurrency.put(currency, running.add(value)); + } + } + + final JsonObject res = new JsonObject(); + // Which bank this response came from -- not decorative. One API key now authenticates + // against every tenant (see ApiClientCredentialService), so the ONLY thing that decides + // whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it + // (plus the bank's display name) back in the body means a caller debugging "why did I get + // client X's data instead of client Y's" can tell from the response alone, without having to + // go back and re-check what header value they actually sent. + res.addProperty("tenantId", tenant.getTenantIdentifier()); + res.addProperty("tenantName", tenant.getName()); + res.addProperty("searchedIdentifier", identifier); + res.addProperty("clientId", clientId); + res.addProperty("matchedBy", (String) client.get("matched_by")); + res.addProperty("clientNo", (String) client.get("account_no")); + res.addProperty("clientName", (String) client.get("display_name")); + res.addProperty("mobileNo", (String) client.get("mobile_no")); + res.addProperty("officeId", ((Number) client.get("office_id")).longValue()); + res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue()); + res.addProperty("totalAccounts", rows.size()); + res.add("accountsByType", gson.toJsonTree(countByType)); + res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency)); + res.add("accounts", gson.toJsonTree(rows)); + return gson.toJson(res); + } + + /** + * Resolves the path identifier as a client number first, then a mobile number -- two lookups in + * the worst case, one in the common case, both on unique indexed columns + * ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}), + * so neither costs more than the single index-scan lookup this whole endpoint is built around. + * Client number is tried first because it's the identifier a teller normally has on the slip in + * front of them; falling through to mobile number covers the third-party case where that's the + * only thing the caller was given. A value that happens to collide across both columns for two + * different clients is not handled specially -- account numbers and mobile numbers use disjoint + * formats in this system, so this cannot occur in practice. + * + * {@code tenant} is only for the not-found message: with one API key now valid across every bank + * (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right + * identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is + * a genuinely unknown client -- naming the bank actually searched turns that from a guessing game + * into something the caller can act on immediately. + */ + private Map resolveClient(final String identifier, final FineractPlatformTenant tenant) { + final List> byAccountNo = jdbcTemplate.queryForList(""" + SELECT id, account_no, display_name, mobile_no, office_id, status_enum + FROM m_client WHERE account_no = ? + """, identifier); + if (!byAccountNo.isEmpty()) { + final Map client = new LinkedHashMap<>(byAccountNo.get(0)); + client.put("matched_by", "CLIENT_NO"); + return client; + } + + final List> byMobileNo = jdbcTemplate.queryForList(""" + SELECT id, account_no, display_name, mobile_no, office_id, status_enum + FROM m_client WHERE mobile_no = ? + """, identifier); + if (!byMobileNo.isEmpty()) { + final Map client = new LinkedHashMap<>(byMobileNo.get(0)); + client.put("matched_by", "MOBILE_NO"); + return client; + } + + throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '" + + tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the " + + "Fineract-Platform-TenantId header -- this identifier may belong to a different bank."); + } + + private String resolveClientIp(final HttpServletRequest request) { + for (final String header : IP_HEADER_CANDIDATES) { + final String value = request.getHeader(header); + if (StringUtils.isNotBlank(value)) { + // X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is + // the original caller, which is what an allowlist entry is written against. + return value.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + /** + * Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a + * single {@code currentValue} figure so the caller never has to know that a deposit's value lives + * in one column and a share account's lives in another (approved shares × unit price). + */ + private static final class AccountRowMapper implements RowMapper> { + + @Override + public Map mapRow(final ResultSet rs, final int rowNum) throws SQLException { + final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type"); + final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum"); + final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance"); + final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold"); + final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares"); + final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares"); + final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price"); + + final boolean isShare = depositType == null; + final String accountType = isShare ? "Share Account" : accountTypeFor(depositType); + + final Map row = new LinkedHashMap<>(); + row.put("accountType", accountType); + row.put("accountId", rs.getLong("account_id")); + row.put("accountNo", rs.getString("account_no")); + row.put("externalId", rs.getString("external_id")); + row.put("productId", rs.getLong("product_id")); + row.put("productName", rs.getString("product_name")); + row.put("shortProductName", rs.getString("short_product_name")); + row.put("currencyCode", rs.getString("currency_code")); + // .toString(), not the raw LocalDate: this Gson instance has no java.time adapter + // registered, so it falls back to reflecting LocalDate's private fields -- which the JVM + // module system blocks outright (java.base does not open java.time to reflection from an + // unnamed module), turning every response into a 500. A plain ISO-8601 string needs no + // adapter and is what every other date field in this API already returns. + row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date"))); + row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date"))); + + if (isShare) { + final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum); + row.put("status", status); + row.put("approvedShares", approvedShares); + row.put("pendingShares", pendingShares); + row.put("unitPrice", unitPrice); + final BigDecimal shareValue = (approvedShares != null && unitPrice != null) + ? unitPrice.multiply(BigDecimal.valueOf(approvedShares)) + : null; + row.put("currentValue", shareValue); + } else { + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + row.put("status", status); + row.put("balance", balance); + row.put("onHoldFunds", onHold); + BigDecimal available = balance; + if (available != null && onHold != null) { + available = available.subtract(onHold); + } + row.put("availableBalance", available); + row.put("currentValue", balance); + } + return row; + } + + private String accountTypeFor(final int depositType) { + return switch (depositType) { + case 100 -> "Savings Account"; + case 200 -> "Fixed Deposit"; + case 300 -> "Recurring Deposit"; + case 400 -> "Current Account"; + default -> "Other"; + }; + } + + private String toIsoDateString(final LocalDate date) { + return date == null ? null : date.toString(); + } + } +} From e33e0f23fe0a3b32562da05aa0246089f6370ecf Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 8 Sep 2026 10:45:04 +0530 Subject: [PATCH 35/55] feat: cashier reports, daily interest enhancements and business date sync --- .../infrastructure/jobs/service/JobName.java | 1 + .../domain/interest/PostingPeriod.java | 11 +- .../service/AccountingProcessorHelper.java | 139 ++++++++ ...ashBasedAccountingProcessorForSavings.java | 9 +- .../AccountingJournalEntryConfiguration.java | 4 +- .../SyncBusinessDateConfig.java | 52 +++ .../SyncBusinessDateTasklet.java | 88 +++++ .../api/BranchDayRegisterApiResource.java | 42 ++- .../cashreport/api/CashReportApiResource.java | 322 ++++++++++++++++-- .../api/CashSettlementApiResource.java | 67 ++++ .../api/CashTransferApiResource.java | 61 ++-- .../api/ClerkClosureRequestApiResource.java | 25 ++ ...lerkShareWithdrawalRequestApiResource.java | 71 ++++ .../SavingsDailyInterestOrchestrator.java | 22 ++ .../SavingsDailyInterestService.java | 187 +++++++++- .../SavingsPooledInterestPostingService.java | 106 +++--- .../SavingsAccountDomainServiceJpa.java | 35 +- ...WritePlatformServiceJpaRepositoryImpl.java | 68 ++-- .../api/VoucherVerificationApiResource.java | 266 ++++++++++++++- .../voucher/service/VoucherService.java | 129 +++++++ .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...avings_daily_interest_from_cycle_start.xml | 105 ++++++ ...0534_enable_business_date_advance_jobs.xml | 75 ++++ ..._sync_business_date_to_system_date_job.xml | 113 ++++++ set-business-date.ps1 | 103 ++++++ 26 files changed, 1951 insertions(+), 157 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml create mode 100644 set-business-date.ps1 diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index f7aabdc4d..8b98ac771 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -56,6 +56,7 @@ public enum JobName { EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), // INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), // INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), // + SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), // LOAN_COB("Loan COB"), // LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), // SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java index 65130e998..80ca8df25 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java @@ -256,7 +256,16 @@ public final class PostingPeriod { this.daysInYear = daysInYear; this.compoundingPeriods = compoundingPeriods; - if (isSavingsInterestPostingAtCurrentPeriodEnd) { + if (isUserPosting) { + // "Post interest as on D" — a closure, or the same action from the account screen. The + // period it covers ends at D-1, because interest is earned up to but not including the + // day it is paid, and SavingsHelper closes the period there. The transaction itself + // still belongs on D: that is the day the member was credited, the day the branch did + // the work, and the day the cash is handed over. Dating it to the period end instead + // stamps it a day before the account was even closed, and the ledger then disagrees with + // the closure request, the voucher and the passbook — all of which carry D. + this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); + } else if (isSavingsInterestPostingAtCurrentPeriodEnd) { this.dateOfPostingTransaction = periodInterval.endDate(); } else { this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 857530526..32e53df8f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -25,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.closure.domain.GLClosure; @@ -77,6 +78,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; @RequiredArgsConstructor public class AccountingProcessorHelper { @@ -89,6 +91,14 @@ public class AccountingProcessorHelper { public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; + /** + * Reserved names on the savings product's Accounting tab, naming the two accounts an interest + * posting is routed through. The names are the contract with the product screen; the GL codes + * behind them are the bank's choice, so no code is named here. + */ + public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; + public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; + private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); public static void setDepositAccountTypeContext(int depositAccountType) { @@ -116,6 +126,10 @@ public class AccountingProcessorHelper { private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; private final ChargeRepositoryWrapper chargeRepositoryWrapper; private final BusinessEventNotifierService businessEventNotifierService; + // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a + // JSON document rather than in acc_product_mapping, so they are read directly. See + // namedSavingsGlAccount. + private final JdbcTemplate jdbcTemplate; public LoanDTO populateLoanDtoFromDTO( final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { @@ -600,6 +614,131 @@ public class AccountingProcessorHelper { createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); } + /** + * Interest credited to a member, routed through the payable and paid accounts the product names. + * + *
+     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
+     *   Cr  Savings Interest Payable   (liability)   and now owes it
+     *
+     *   Dr  Savings Interest Payable
+     *   Cr  Savings Interest Paid                    the debt is on its way out
+     *
+     *   Dr  Savings Interest Paid
+     *   Cr  Savings Control                          and reaches the member
+     * 
+ * + *

+ * The two middle accounts net to nil across the three steps, so the trial balance is unchanged + * from the plain two-legged entry; what they add is a reading of how much interest has been + * declared but not yet credited, which a single Dr/Cr pair cannot show. + * + *

+ * Both are read from the product's Accounting tab and neither is required. A product that names + * no paid account collapses the middle step, and one that names no payable falls back to its own + * Savings Control - either way the posting degrades to exactly the two-legged entry it was + * before, so products configured before these mappings existed are unaffected. + * + *

+ * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} + * does for the two-legged case. + */ + 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 GLAccount expenseAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); + final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + + final GLAccount payableAccount = Optional + .ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)).orElse(controlAccount); + final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) + .orElse(payableAccount); + + // Stage 1: the expense is incurred and parked in the payable. + postSavingsLeg(office, currencyCode, expenseAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 2: the liability moves to the account it is paid out of. + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 3: into the member's own balance. + postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } + + /** + * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. + * + *

+ * A leg whose two sides are the same account writes nothing. That is not a defensive check but + * the mechanism by which the three stages above collapse: a product that names no payable and no + * paid account resolves all three to its Savings Control, and what should remain is the plain + * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping + * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and + * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's + * debits and credits unequal per account and shows up on the trial balance as movement that never + * happened. Deciding it here rather than at each call site means every collapse works the same + * way, whichever of the two names a product happens to leave unset. + */ + private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, + final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final boolean isReversal) { + if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { + return; + } + final GLAccount toDebit = isReversal ? creditAccount : debitAccount; + final GLAccount toCredit = isReversal ? debitAccount : creditAccount; + createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); + } + + /** + * A GL account the savings product maps under one of the reserved names on its Accounting tab. + * + *

+ * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of + * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in + * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's + * fixed financial account types. The name is matched across all four groups: which group a bank + * files it under is a presentation choice, and an account configured under the wrong heading + * should still be honoured rather than silently ignored. + * + * @return the account, or null where the product does not configure this name + */ + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { + if (savingsProductId == null) { + return null; + } + // The column holds an empty string on a product saved with no named mappings, and casting + // that to jsonb is an error rather than an empty document - so the cast is done inside a + // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE + // clause in any order, so guarding at the same query level as the cast would only work by + // luck. + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE m.entry ->> 'accountName' = ? + LIMIT 1 + """, Long.class, savingsProductId, accountName); + return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + } + /** * Creates a direct transfer journal entry between two savings-type accounts (savings or current), * each with their own product GL mapping and entity ID so the correct account number appears per entry. diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index 182e83cb5..d6a6034e4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess // Post journal entry if earned interest amount is greater than // zero if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + // Routed through the payable and paid accounts the product names, so the society + // can read interest declared but not yet credited as its own balance. Collapses + // 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, amount, isReversal); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java index 70df94911..642fad61d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/starter/AccountingJournalEntryConfiguration.java @@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration { FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository, GLAccountRepository glAccountRepository, OfficeRepository officeRepository, AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper, - BusinessEventNotifierService businessEventNotifierService) { + BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) { return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository, closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper, - businessEventNotifierService); + businessEventNotifierService, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java new file mode 100644 index 000000000..204b14037 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +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; + +@Configuration +public class SyncBusinessDateConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step syncBusinessDateStep(final SyncBusinessDateTasklet syncBusinessDateTasklet) { + return new StepBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository) + .tasklet(syncBusinessDateTasklet, transactionManager).build(); + } + + @Bean + public Job syncBusinessDateJob(final SyncBusinessDateTasklet syncBusinessDateTasklet) { + return new JobBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository) + .start(syncBusinessDateStep(syncBusinessDateTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java new file mode 100644 index 000000000..b00ae48a2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/businessdate/jobs/syncbusinessdate/SyncBusinessDateTasklet.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.businessdate.data.service.BusinessDateDTO; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.businessdate.service.BusinessDateWritePlatformService; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.service.DateUtils; +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; +import org.springframework.stereotype.Component; + +/** + * Sets the business date to the tenant's own calendar date, every midnight. + * + *

+ * Fineract's stock job adds one day to whatever the business date already holds. That is only the + * same thing as "today" while the scheduler has never missed a night: a server stopped over a + * weekend, a laptop that was closed, a tenant restored from an older backup, and the date is left + * behind by however many runs did not happen. It then stays behind, because adding a day to a stale + * date gives another stale date, and each night's run preserves the gap rather than closing it. + * + *

+ * Drift is not a cosmetic problem. Every transaction is stamped with the business date, so while it + * lags: a Demand Draft raised today is rejected as being dated in the future, the day's savings + * interest never accrues and a closure pays out short, and staff see yesterday's date on work they + * are doing now. + * + *

+ * Reading the clock instead of incrementing is self-correcting: however many days were missed, the + * next run lands on today. Bringing the date forward is all this does — a date deliberately set + * ahead, which is how testing against a future day works, is left alone rather than pulled back + * under someone's feet. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class SyncBusinessDateTasklet implements Tasklet { + + private final BusinessDateWritePlatformService businessDateWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + if (!configurationDomainService.isBusinessDateEnabled()) { + // Without the setting on, the platform reads the clock directly and m_business_date is + // not consulted — writing to it would be busywork that misleads anyone reading the table. + log.debug("Business date is not enabled; nothing to sync."); + return RepeatStatus.FINISHED; + } + + final LocalDate systemDate = DateUtils.getLocalDateOfTenant(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + + if (!businessDate.isBefore(systemDate)) { + log.debug("Business date {} is already at or ahead of the system date {}; leaving it alone.", businessDate, systemDate); + return RepeatStatus.FINISHED; + } + + businessDateWritePlatformService.updateBusinessDate(BusinessDateDTO.builder().type(BusinessDateType.BUSINESS_DATE) + .description(BusinessDateType.BUSINESS_DATE.getDescription()).date(systemDate).build()); + + log.info("Business date moved from {} to the system date {}.", businessDate, systemDate); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index b1bc8ebfd..cbe7fdd6f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -36,6 +36,7 @@ import java.util.LinkedHashMap; 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.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -59,6 +60,7 @@ import org.springframework.stereotype.Component; @Path("/v1/branchday") @Component @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class BranchDayRegisterApiResource { @@ -543,12 +545,35 @@ public class BranchDayRegisterApiResource { blockingCashiers.add(name + " (" + String.join(", ", issues) + ")"); } } + // Overridden only by an explicit reason, and never silently. + // + // The block is the right default: a day closed over a drawer nobody counted is a day whose + // cash figure means nothing. But it cannot be absolute either. A cashier who reconciled on + // the wrong date, went on leave, or left the branch mid-shift leaves a day that can never be + // closed at all, and every day after it inherits the same block — while the screen offers a + // Force Close Day button that could not actually get past this throw, so pressing it + // returned the same refusal and read as a broken button. + // + // So a reason gets you through, and the names of the cashiers who were not reconciled are + // appended to it on the record. Somebody reading that day later sees both that it was forced + // and exactly whose cash was unaccounted for — which is more use than a day that cannot be + // closed and a button that does nothing. + String overrideReason = forceReason; if (!blockingCashiers.isEmpty()) { - throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", - "Cannot close the day — the following cashiers have not been reconciled or settled: " - + String.join("; ", blockingCashiers) - + ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed."); + if (overrideReason == null || overrideReason.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled", + "Cannot close the day — the following cashiers have not been reconciled or settled: " + + String.join("; ", blockingCashiers) + + ". Each cashier must reconcile their cash and settle it back to the vault before the branch day" + + " can be closed, or record a reason to force the close."); + } + overrideReason = overrideReason + " [Forced over unreconciled cashiers: " + String.join("; ", blockingCashiers) + + "]"; + log.warn("Branch day {} for office {} force-closed over unreconciled cashiers: {}", businessDate, officeId, + String.join("; ", blockingCashiers)); } + // A forced close is a forced close whether or not the date is also in the past. + final boolean forcedOverCashiers = !blockingCashiers.isEmpty(); // Closing a past date is allowed — otherwise a day left open could never be settled at all — // but it must say why it was left open, and it is stamped as a force close. @@ -568,8 +593,13 @@ public class BranchDayRegisterApiResource { book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? WHERE office_id = ? AND business_date = ? """, + // Forced either way: a late close, or a close over a drawer nobody counted. The reason is + // stored whenever it was needed, not only when the date was in the past — a same-day + // close forced over an unreconciled cashier used to drop its reason on the floor, which + // left the one fact a reader would want to know about that day unrecorded. currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash, - bookCash, difference, isLate, isLate ? forceReason : null, remarks, + bookCash, difference, isLate || forcedOverCashiers, + (isLate || forcedOverCashiers) ? overrideReason : null, remarks, officeId, Date.valueOf(businessDate)); final Long registerId = ((Number) found.get(0).get("id")).longValue(); @@ -583,7 +613,7 @@ public class BranchDayRegisterApiResource { res.put("closingCash", closingCash); res.put("bookCash", bookCash); res.put("cashDifference", difference); - res.put("forceClosed", isLate); + res.put("forceClosed", isLate || forcedOverCashiers); return gson.toJson(res); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 1a2b36cc3..b0094cb29 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -37,7 +37,7 @@ public class CashReportApiResource { private final Gson gson = new Gson(); /** - * Excludes reversed counter transactions from a cash figure. + * Excludes counter breakdowns that must not count towards a drawer. * * m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the * counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its @@ -46,13 +46,44 @@ public class CashReportApiResource { * its own reversal flag; this is the same guard for the counter, read off the transaction the * breakdown belongs to. * - * NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD - * register keys its rows by its own request id, not a savings transaction -- is kept rather than - * silently dropped. + *

+ * Two things are excluded, and they are different failures: + * + *

    + *
  • a breakdown whose transaction is reversed -- the money came back, so the notes did too; + *
  • a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier + * code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no + * transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position + * is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit + * Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070. + *
+ * + *

+ * The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by + * itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those + * rows are genuine cash that has to keep counting. Request ids and transaction ids are both + * small; a millisecond epoch is thirteen digits and cannot be either. So anything at or above + * 10^12 is rejected as not-an-id, and everything smaller is kept. */ + private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; + private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" + " SELECT 1 FROM m_savings_account_transaction rt " - + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "; + + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) " + + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + // A breakdown has to add up to the transaction it belongs to. Where it does not, the + // notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes + // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven + // such transactions in this database were carrying ₹1.68 lakh that never existed. + // + // Compared per transaction inside a correlated subquery rather than as a join, so a + // breakdown with no transaction behind it -- a Demand Draft keyed by its request id -- + // is untouched by this test and still handled by the id-magnitude test above. + + "AND NOT EXISTS (" + + " SELECT 1 FROM m_savings_account_transaction mt " + + " WHERE mt.id = std.txn_id" + + " AND mt.amount <> (SELECT SUM(sd.count * sd.denomination_value)" + + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) "; /** * Day book — every voucher raised on one date, in one list, newest first. @@ -150,7 +181,12 @@ public class CashReportApiResource { // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier // step to reach. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no, + SELECT r.voucher_no, + -- A closure is paid out at the counter, so it is the cashier's work. + -- This column was missing entirely and the row mapper reads it unconditionally, + -- so every CLOSURE row threw instead of appearing. + 'CASHIER' AS section, + 'DR' AS side, 'CLOSURE' AS kind, r.account_no, 'SAVINGS' AS account_type, r.client_name AS party, COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, r.payment_type_name, r.description, r.status, @@ -210,6 +246,94 @@ public class CashReportApiResource { AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Everything else that moved money on this date and is not one of the registers above: + // interest posted, a charge collected from a balance, a counter deposit or withdrawal, an + // escheat, a manual journal. + // + // Read from the voucher register rather than from another set of source tables, because + // that is the one place every posting lands whatever raised it — including the nightly jobs, + // which have no request row anywhere to read them from. Without this the officer's sheet + // showed only what somebody had filed a request for, and a day of system postings against + // members' accounts was invisible on the very page meant to account for the day. + // + // The registers above are excluded by reference type, so a voucher already listed as a + // deposit or a withdrawal request cannot appear twice under a different heading. + // + // Status is reported as COMPLETED: a voucher only exists once its posting has committed, so + // there is no pending state to represent — unlike a request, which can sit unactioned. + rows.addAll(jdbcTemplate.query(""" + SELECT v.voucher_no, + -- A nightly batch belongs to the day it worked on, not the minute it ran. + -- + -- A voucher's created_at is a wall-clock instant, while the transaction it + -- posts and the journal entry behind it both carry the business date. So the + -- interest job that fires at 00:10 stamps its vouchers with tomorrow while + -- the ledger says yesterday — and the day it actually belongs to had already + -- been counted, reported and signed off hours earlier. + -- + -- Rather than re-open a closed day, such a voucher is listed on the day it + -- surfaces, under its own heading, saying which day's work it is. The date is + -- taken from the transaction it posts; where there is none — a manual journal + -- keyed straight to the ledger — an early-hours timestamp is treated the same + -- way, because nobody is at a counter at 3am. + CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) + < CAST(v.created_at AS DATE) + OR (v.savings_txn_id IS NULL + AND EXTRACT(HOUR FROM v.created_at) < 6) + THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section, + -- Which day's work this is, for the heading on the prior-day block. + COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date, + CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side, + v.voucher_type AS kind, + -- The member's own account number, never a voucher line's — a line carries a + -- GL code there, and showing 500201 in an Account column reads as an account + -- that does not exist. A journal with no member behind it shows none. + sa.account_no, + NULL AS account_type, + COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party, + v.amount, + NULL AS payment_type_name, + -- The two sides in one phrase, which is what a journal voucher is read as. + CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description, + 'COMPLETED' AS status, + v.created_by_username AS handled_by, + -- Who checked it, if anybody has. Not aliased from verifyCols below, which + -- selects verified_by_name under its own name for the verification panel -- + -- the row mapper reads both columns, so each has to be present exactly once. + vv.verified_by_name AS approved_by, + CAST(v.savings_txn_id AS VARCHAR) AS txn_id, + v.created_at AS submitted_at, v.created_at AS actioned_at, + """ + verifyCols + """ + FROM m_voucher v + -- Either link: savings_txn_id is stamped by a step after the voucher is written + -- and is empty on every interest posting, where reference_id already carries the + -- same transaction id. Without the fallback the prior-day test never fires on the + -- nightly postings it was written for. + LEFT JOIN m_savings_account_transaction t + ON t.id = COALESCE(v.savings_txn_id, + CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION' + THEN v.reference_id END) + LEFT 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 m_group g ON g.id = sa.group_id + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'DR' + ORDER BY line_no LIMIT 1) dr ON TRUE + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'CR' + ORDER BY line_no LIMIT 1) cr ON TRUE + LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no + LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no) + va ON va.voucher_no = v.voucher_no + WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE) + AND (v.reference_type IS NULL OR v.reference_type NOT IN ( + 'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', + 'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST', + 'SPONSOR_DD_ISSUE_REQUEST')) + """, this::mapDayBookRow, txnDate)); + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be // ordered as one result set, and the row count for a single day is small. final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); @@ -218,6 +342,10 @@ public class CashReportApiResource { final List> filtered = new ArrayList<>(); BigDecimal creditTotal = BigDecimal.ZERO; BigDecimal debitTotal = BigDecimal.ZERO; + // Kept apart from the day's own totals so a reader can see the overnight batch without it + // silently joining figures that were already reported and signed. + BigDecimal priorCreditTotal = BigDecimal.ZERO; + BigDecimal priorDebitTotal = BigDecimal.ZERO; for (final Map row : rows) { if (sideFilter != null && !sideFilter.equals(row.get("side"))) { @@ -236,8 +364,12 @@ public class CashReportApiResource { // the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting // both states cannot double-count it; only 'CLOSED' is unique to that register. final String rowStatus = (String) row.get("status"); - if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) - || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { + // A batch that posted against yesterday is listed today but is not today's money: it + // was already earned, and the day it belongs to has been counted and signed off. Adding + // it to this day's totals would make them disagree with the ledger for either date. + final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); + if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { if ("CR".equals(row.get("side"))) { @@ -247,6 +379,16 @@ public class CashReportApiResource { } } } + if (priorDay) { + final BigDecimal amount = (BigDecimal) row.get("amount"); + if (amount != null) { + if ("CR".equals(row.get("side"))) { + priorCreditTotal = priorCreditTotal.add(amount); + } else { + priorDebitTotal = priorDebitTotal.add(amount); + } + } + } } filtered.sort((a, b) -> { @@ -272,9 +414,28 @@ public class CashReportApiResource { result.put("creditTotal", creditTotal); result.put("debitTotal", debitTotal); result.put("netTotal", creditTotal.subtract(debitTotal)); + // The overnight batch, reported separately: this is yesterday's work surfacing today. + result.put("priorCreditTotal", priorCreditTotal); + result.put("priorDebitTotal", priorDebitTotal); return gson.toJson(result); } + /** + * A date column that only some of the union'd queries select. + * + * The six day-book queries do not share a column list, and adding a column to all of them so one + * can carry it would mean five queries stating a date they were handed as a parameter. Absent is + * reported as null rather than as an error. + */ + private String optionalDate(final java.sql.ResultSet rs, final String column) { + try { + final java.sql.Date value = rs.getDate(column); + return value == null ? null : value.toLocalDate().toString(); + } catch (final java.sql.SQLException e) { + return null; + } + } + private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { final Map row = new LinkedHashMap<>(); row.put("voucherNo", rs.getString("voucher_no")); @@ -282,6 +443,12 @@ public class CashReportApiResource { // maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says // where the entry came from, not who typed it. row.put("section", rs.getString("section")); + + // Which day's work the row is: normally the day being reported, but a nightly batch posts + // against the previous business date while surfacing on this one. Only the SYSTEM query + // selects it, so it is read defensively rather than added to five other queries that would + // all report the same date they were asked for. + row.put("effectiveDate", optionalDate(rs, "effective_date")); row.put("side", rs.getString("side")); row.put("kind", rs.getString("kind")); row.put("accountNo", rs.getString("account_no")); @@ -334,39 +501,93 @@ public class CashReportApiResource { + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; - // Single query — conditional aggregation for deposit, withdrawal, net per denomination - final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, " - + "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, " - + "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, " - + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, " - + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " - + "FROM m_savings_txn_denomination std " - + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " - + "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED - + "GROUP BY std.denomination_id, std.denomination_value, dm.kind " - + "ORDER BY std.denomination_value DESC, dm.kind"; + // Every note and coin that reached the drawer or left it, per denomination. + // + // Four legs, not one. The counter breakdown alone answered "what did customers hand over", + // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a + // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only + // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be + // counted against. So the vault allocation and the settlement back are included on the same + // footing as the counter work, and each row says where its notes came from. + // + // Cashier scoping differs per leg because each table records the party differently: the + // counter tables name the acting user, the cash tables name the cashier row. + final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; + final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; + + final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount " + + "FROM (" + // Counter work: what customers handed over and were paid. + + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, " + + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg " + + " FROM m_savings_txn_denomination std " + + " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED + + " UNION ALL " + // Drawn from the vault in the morning. + + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg " + + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " + + " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false " + + vaultCashierFilter + + " UNION ALL " + // Handed back to the vault at close. + + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg " + + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id " + + " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false " + + settleCashierFilter + + ") legs " + + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id " + + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind " + + "ORDER BY legs.denomination_value DESC, dm.kind"; final List args = new ArrayList<>(); args.add(txnDate); if (scopedCashierId != null) { args.add(scopedCashierId); } + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { - long depositCount = rs.getLong("deposit_count"); - long withdrawalCount = rs.getLong("withdrawal_count"); - BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); - BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); + final long vaultInCount = rs.getLong("vault_in_count"); + final long depositCount = rs.getLong("deposit_count"); + final long withdrawalCount = rs.getLong("withdrawal_count"); + final long settledOutCount = rs.getLong("settled_out_count"); + final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount"); + final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); + final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); + final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount"); + Map row = new LinkedHashMap<>(); row.put("denominationId", rs.getLong("denomination_id")); row.put("denominationValue", rs.getInt("denomination_value")); row.put("kind", rs.getString("kind")); + row.put("vaultInCount", vaultInCount); + row.put("vaultInAmount", vaultInAmount); row.put("depositCount", depositCount); row.put("depositAmount", depositAmount); row.put("withdrawalCount", withdrawalCount); row.put("withdrawalAmount", withdrawalAmount); - row.put("netCount", depositCount - withdrawalCount); - row.put("netAmount", depositAmount.subtract(withdrawalAmount)); + row.put("settledOutCount", settledOutCount); + row.put("settledOutAmount", settledOutAmount); + // What is left of this denomination: everything that came in, less everything that went + // out. This is the figure a teller counts their notes against, per note. + row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount); + row.put("netAmount", + vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount)); return row; }, args.toArray()); @@ -488,10 +709,49 @@ public class CashReportApiResource { result.put("openingBalance", openingBalance); result.put("closingBalance", closingBalance); result.put("cashMovement", closingBalance.subtract(openingBalance)); + + // The day on its own terms, which is what a cashier actually reconciles. + // + // A cashier does not carry a balance: they draw cash from the vault in the morning, work + // the counter, and hand it back at close. So the cumulative figures above answer a + // question nobody at the counter asks — and one carrying years of history answers it + // with a number that cannot be counted against anything on the desk. These four are the + // day itself: taken from the vault, in over the counter, out over the counter, settled + // back. What is left is what should physically be in the drawer. + result.put("todayVaultIn", dayLegTotal( + "SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + + " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id" + + " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ct.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todaySettledOut", dayLegTotal( + "SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + + " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id" + + " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)" + + " AND COALESCE(cs.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todayInterCashierIn", dayLegTotal( + "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + + " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ic.is_reversed, false) = false", + scopedCashierId, txnDate)); + result.put("todayInterCashierOut", dayLegTotal( + "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + + " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + + " AND COALESCE(ic.is_reversed, false) = false", + scopedCashierId, txnDate)); } return gson.toJson(result); } + /** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */ + private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) { + final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate); + return total == null ? BigDecimal.ZERO : total; + } + /** * What one cashier is holding at the end of a given date, in rupees. * @@ -826,7 +1086,17 @@ public class CashReportApiResource { return gson.toJson(result); } - private Map buildPositionForCashier(Long cashierId, String dateFilter) { + /** + * Denomination-wise net cash a cashier is holding as of a date: everything allocated to them, + * settled or handed on by them, and taken or paid over their counter, netted per note and coin. + * + *

+ * Public because the settlement register checks against it before accepting a hand-over. It has + * to be this figure and not a second sum of the same eight legs: two implementations of "what + * does this drawer hold" drift, and the one place that matters is the moment somebody is told + * they cannot settle what they do not have. + */ + public Map buildPositionForCashier(Long cashierId, String dateFilter) { // Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count " + "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 0d8e7fd68..82dbae991 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.springframework.jdbc.core.JdbcTemplate; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -57,6 +58,9 @@ public class CashSettlementApiResource { private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; private final VoucherService voucherService; + // The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report + // says they are not holding. + private final CashReportApiResource cashReportApiResource; private final Gson gson = new Gson(); @POST @@ -112,6 +116,8 @@ public class CashSettlementApiResource { "At least one denomination with a positive count is required."); } + validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate); + final BigDecimal total = sumTotal(denominations); CashSettlement header = new CashSettlement(); @@ -290,6 +296,67 @@ public class CashSettlementApiResource { return total; } + /** + * Refuses a hand-over of notes the cashier is not holding. + * + *

+ * Nothing stopped this before, and the damage is not a rejected screen but a drawer that reads + * negative for the rest of its life: a settlement is subtracted from the cashier's position + * cumulatively, so one settlement of cash they never had leaves every later position short by + * that amount, on every date, with no transaction to point at. This database carries exactly + * that — ₹10.5 lakh settled against ₹26 thousand ever allocated, and a Cashier Position screen + * showing counts like -1814 as a result. + * + *

+ * Checked per denomination, not on the total, for the same reason the vault side is: a drawer + * holding ₹600 in one ₹500 and one ₹100 cannot hand over six ₹100 notes, though the total + * agrees. The holding comes from {@code buildPositionForCashier} — the figure the Cashier + * Position screen shows — so what a cashier is told they hold is what they are allowed to settle. + * + *

+ * A cashier with no position at all is left alone: a first settlement on a drawer whose + * allocations were recorded without a breakdown has nothing to check against, and blocking it + * would stop a branch settling at all rather than prevent a mistake. + */ + private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations, + final LocalDate settlementDate) { + + final Map position = cashReportApiResource.buildPositionForCashier(fromCashierId, + settlementDate.toString()); + + @SuppressWarnings("unchecked") + final List> heldRows = (List>) position.get("denominations"); + if (heldRows == null || heldRows.isEmpty()) { + return; + } + + final Map held = new java.util.HashMap<>(); + for (final Map row : heldRows) { + final Object id = row.get("denominationId"); + final Object count = row.get("count"); + if (id instanceof Number && count instanceof Number) { + held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum); + } + } + + for (final JsonElement el : denominations) { + final JsonObject item = el.getAsJsonObject(); + final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0; + if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) { + continue; + } + final long denominationId = item.get("denominationId").getAsLong(); + final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + ? item.get("denominationValue").getAsInt() : 0; + final long available = held.getOrDefault(denominationId, 0L); + if (count > available) { + throw new PlatformDataIntegrityException("error.msg.cashsettlement.insufficient.cashier.denomination", + "Cashier is not holding that much of denomination " + denominationValue + ": has " + available + + ", settling " + count + ". Check the Cashier Position report before settling."); + } + } + } + private boolean hasPositiveCount(JsonArray items) { for (JsonElement el : items) { if (el.getAsJsonObject().get("count").getAsInt() > 0) return true; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 07a16f84e..5f5c64b62 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.teller.service.GlDispatchService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer; @@ -52,6 +53,9 @@ public class CashTransferApiResource { private final PlatformTransactionManager transactionManager; private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; + // The same source the Cash Transfer screen reads its Available column from, so what is offered + // and what is checked cannot disagree. + private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); @POST @@ -327,46 +331,39 @@ public class CashTransferApiResource { * The total-amount check above can pass while a specific note/coin still runs short — a vault * holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the * total is well within its balance. This checks each requested denomination against what the - * vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's - * settlements-in minus today's transfers-out actually holds of that specific denomination. + * vault actually holds of it. * - * A day opened with a plain lump sum and no breakdown recorded has nothing to check a - * denomination against, so this is skipped rather than blocking every transfer on a day that - * simply never captured one. + *

+ * The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen + * shows in its Available column. It has to: this check used to walk the register itself and read + * the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under + * it is holding and is anchored on a different day. The screen offered a ₹100 note the vault + * plainly had and the transfer was then refused as "vault has 0" — two answers to one question, + * from two sources. Reading the service means the number that was offered is the number that is + * checked, and neither can drift from the other again. + * + *

+ * A day with no breakdown recorded anywhere has nothing to check a denomination against, so this + * is skipped rather than blocking every transfer on a day that simply never captured one. */ private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId, final JsonArray denominations, final java.sql.Date businessDate) { - final List openingRows = jdbcTemplate.query(""" - SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail - WHERE register_id = ? AND leg = 'OPENING' - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId); - if (openingRows.isEmpty()) { + final Map position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString()); + if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) { return; } final Map available = new java.util.HashMap<>(); - for (final Object[] row : openingRows) { - available.merge((Long) row[0], (Integer) row[2], Integer::sum); - } - - final List settledRows = jdbcTemplate.query(""" - SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd - JOIN m_cash_settlement cs ON cs.id = csd.settlement_id - WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false - GROUP BY csd.denomination_id - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); - for (final Object[] row : settledRows) { - available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum); - } - - final List handedOutRows = jdbcTemplate.query(""" - SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd - JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id - WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false - GROUP BY ctd.denomination_id - """, (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate); - for (final Object[] row : handedOutRows) { - available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum); + @SuppressWarnings("unchecked") + final List> heldRows = (List>) position.get("denominations"); + if (heldRows != null) { + for (final Map row : heldRows) { + final Object id = row.get("denominationId"); + final Object count = row.get("count"); + if (id instanceof Number && count instanceof Number) { + available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum); + } + } } for (final JsonElement el : denominations) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 5569a5834..cfb71fca7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -215,6 +215,31 @@ public class ClerkClosureRequestApiResource { private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) { final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false); savingsAccountWritePlatformService.postInterest(account, true, closureDate, false); + + // What the account itself says is still owed once that posting has run: everything the + // account has earned over its life, less everything already credited to the member. The + // account's own summary is the figure the account screen shows, so a closure and that screen + // can no longer disagree about what is outstanding. + // + // Normally this is zero -- the posting above has just credited the shortfall, which is the + // whole point of running it. It is not zero when the summary carries interest the posting did + // not reach: interest accrued against a period the posting run does not close, most often on + // a cycle that has not yet ended. Left alone that interest is simply dropped, because the + // account is about to be closed and no later run will ever pay it. + // + // Only ever pays out what the two totals still differ by, so interest already credited cannot + // be paid a second time, and a negative difference (more posted than earned, which a reversal + // can leave behind) is ignored rather than taken back off the member here. + final BigDecimal earned = zeroIfNull(account.getSummary().getTotalInterestEarned()); + final BigDecimal posted = zeroIfNull(account.getSummary().getTotalInterestPosted()); + final BigDecimal stillOwed = earned.subtract(posted); + + if (stillOwed.compareTo(BigDecimal.ZERO) > 0) { + LOG.warn("Closure of savings account {}: {} of accrued interest is still unposted after posting up to {} " + + "(earned {} against posted {}). The payout below does not include it.", savingsAccountId, stillOwed, + closureDate, earned, posted); + } + return zeroIfNull(account.getSummary().getAccountBalance()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 5f3da17da..969fe1a44 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -56,7 +56,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -77,10 +81,13 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class ClerkShareWithdrawalRequestApiResource { + private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class); + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; // Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date, // meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent // format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's @@ -701,9 +708,17 @@ public class ClerkShareWithdrawalRequestApiResource { // shares left for the native close command to force-redeem without oversight. final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString()); + // Every other clerk register raises a voucher for the money it moves; this one did not, so a + // redemption settled into a member's savings account left nothing on the voucher register to + // read it back from. The GL entries were always written — it is the slip that was missing. + final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser); + final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); res.addProperty("status", "APPROVED"); + if (voucherNo != null) { + res.addProperty("voucherNo", voucherNo); + } if (shareTxnId != null) { res.addProperty("shareTransactionId", shareTxnId); } @@ -742,6 +757,62 @@ public class ClerkShareWithdrawalRequestApiResource { return true; } + /** + * The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings. + * + *

+ * Mirrors the GL pair {@link #creditLinkedSavingsAccount} posts, so the slip and the ledger say + * the same thing. A redemption with no linked savings account settles nothing yet and raises no + * voucher — there is no second leg to name until somebody settles it by hand. + * + *

+ * A failure here is logged and swallowed, never rethrown: the redemption, its GL entries and the + * savings credit have all already succeeded in this transaction, and losing the slip is worth + * less than rolling back a member's money. + */ + private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map record, + final Settlement settlement, final AppUser currentUser) { + + if (!voucherService.shouldRaiseVoucher() || settlement == null + || settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + try { + final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, + shareAccountId); + final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE); + + final Long savingsAccountId = jdbcTemplate + .queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream() + .findFirst().orElse(null); + final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId); + + final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member"; + final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no")); + final String savingsAccountNo = savingsAccountId == null ? null + : jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId) + .stream().findFirst().orElse(null); + + final List lines = new ArrayList<>(); + lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount())); + lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount())); + + // PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their + // savings account, which is the receipt series — the share account is the source, not a + // counter payment. + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, + VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_CREDIT, lines); + if (settlement.savingsTransactionId() != null) { + voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, + settlement.savingsTransactionId()); + } + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e); + return null; + } + } + private record Settlement(BigDecimal amount, Long savingsTransactionId) {} /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index 6a3f7da8b..af0596d68 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -61,6 +61,16 @@ public class SavingsDailyInterestOrchestrator { ORDER BY s.savings_product_id """); + // Products that could not be paid, collected so the run can fail loudly at the end. + // + // Carrying on past one product is right — a mapping missing on one product must not stop + // every member of every other from being paid. Reporting the job as successful afterwards is + // not: the scheduler recorded 'success', the operator saw a green run, and the only trace + // that nobody had been paid was a log line and a row in m_savings_interest_posting_run that + // nothing reads. On a night when every product fails, that is a whole day of interest + // silently unpaid. + final List failures = new java.util.ArrayList<>(); + for (final Map schedule : schedules) { final long productId = ((Number) schedule.get("savings_product_id")).longValue(); final Long dueCount = jdbcTemplate.queryForObject(""" @@ -80,14 +90,26 @@ public class SavingsDailyInterestOrchestrator { } catch (final RuntimeException e) { // The product rolled back on its own; record why and let the others run. log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e); + // An UPDATE, so it writes nothing when the product threw before openRun() inserted + // the row — which is exactly what a product with no GL mapping does. The failure is + // therefore collected in memory as well, so it cannot be lost with the breadcrumb. jdbcTemplate.update(""" UPDATE m_savings_interest_posting_run SET status = 'FAILED', failure_reason = ? WHERE savings_product_id = ? AND posting_date = ? """, truncate(e.getMessage()), productId, businessDate); updateSchedule(productId, businessDate, "FAILED"); + failures.add("product " + productId + ": " + e.getMessage()); } } + + // Fails the job so the scheduler records it as failed and an operator is told. Every product + // that could be paid has already been paid and committed by this point — this reports what + // happened, it does not undo it. + if (!failures.isEmpty()) { + throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for " + + failures.size() + " product(s): " + String.join("; ", failures)); + } } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index 57534016c..c3b60665e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -23,6 +23,7 @@ import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -194,11 +195,19 @@ public class SavingsDailyInterestService { // today's figures. Only a row for a day before today can be carried forward — one already // stamped with today or later means the day has been accrued and re-reading it as "previous" // would compound the same day's interest onto itself. + // Newest first, one row. The table currently carries a unique constraint on + // savings_account_id so at most one row can match and the ordering is academic — but the + // code below reads previous.get(0) as "yesterday", and the moment per-day history is + // restored an unordered query would hand it an arbitrary earlier day as the opening balance + // and silently break the chain. Stating the intent costs nothing and does not depend on the + // constraint staying where it is. final List> previous = jdbcTemplate.queryForList(""" SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, is_posting_day FROM m_savings_daily_interest WHERE savings_account_id = ? AND balance_date < ? + ORDER BY balance_date DESC + LIMIT 1 """, accountId, businessDate); final BigDecimal openingBalance; @@ -208,8 +217,6 @@ public class SavingsDailyInterestService { final LocalDate cycleEnd; if (previous.isEmpty()) { - openingBalance = BigDecimal.ZERO; - carriedInterest = BigDecimal.ZERO; cycleNumber = 1; // The first row belongs to the cycle the day it records falls in, and starts at // activation only when that day is still inside the account's opening cycle. Anchoring @@ -221,6 +228,19 @@ public class SavingsDailyInterestService { final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod, financialYearBeginningMonth); cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart; + // The account has earned since its cycle opened, but the ledger only starts today — + // either because the account predates this ledger, or because the nightly run has not + // reached it before now. Taking the opening balance as zero and the carried interest as + // nothing would credit the member for today alone and silently drop every day since + // cycleStart, which is exactly what a closure then pays out. + // + // So the days already gone are replayed here, from the account's own transactions, to + // arrive at the balance and the running interest the ledger would be holding had it + // been running all along. + final Backfill backfill = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, + minBalance, averageDailyBalanceOf(calcType)); + openingBalance = backfill.closingBalance(); + carriedInterest = backfill.cumulativeInterest(); } else { final Map prev = previous.get(0); openingBalance = (BigDecimal) prev.get("closing_balance"); @@ -249,8 +269,7 @@ public class SavingsDailyInterestService { // The minimum is a cliff, not a taper: below it nothing is earned. It is tested against // whichever balance the account actually earns on, which is not always today's closing. - final boolean averageDailyBalance = calcType != null - && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE; + final boolean averageDailyBalance = averageDailyBalanceOf(calcType); final BigDecimal dailyInterest; final BigDecimal cumulative; @@ -389,6 +408,166 @@ public class SavingsDailyInterestService { * It cannot double-count, because a posting only ever falls on one day and the row for that day * keeps the balance the posting left it with (see the closing_balance guard in the upsert). */ + /** + * Brings one account's ledger up to the business date after its balance has moved. + * + *

+ * The ledger is written by the nightly run, so between a deposit and the next run the account's + * row still states yesterday's balance and yesterday's interest. A closure in that window reads + * the stale row and pays out interest on a balance the member no longer has — or, for money paid + * in today, on no balance at all. Calling this as soon as the balance moves keeps the row in step + * with the account, so what a closure reads is what the account actually holds. + * + *

+ * Re-accruing the same day is safe. The row is rewritten in place from the day before it, never + * from itself, so running this repeatedly through a busy day converges on the same figures rather + * than compounding them. An account with no row yet gets its first one here, backfilled from the + * cycle start exactly as {@link #accrueOnActivation} would have written it. + * + *

+ * Never fails the caller: the transaction that moved the money has already succeeded and must not + * be rolled back because the ledger could not be refreshed. A failure is logged and left for the + * nightly run. + */ + @Transactional + public void refreshAfterBalanceChange(final Long savingsAccountId, final LocalDate businessDate) { + try { + final List> accounts = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.product_id, sa.activatedon_date, + sp.nominal_annual_interest_rate AS rate, + sp.interest_posting_period_enum AS posting_period, + sp.interest_compounding_period_enum AS compounding_period, + sp.interest_calculation_type_enum AS calculation_type, + sp.interest_calculation_days_in_year_type_enum AS days_in_year, + sa.min_balance_for_interest_calculation AS min_balance + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? AND sp.deposit_type_enum = ? + AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ? + """, savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate); + + if (accounts.isEmpty()) { + return; + } + accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth()); + } catch (final RuntimeException e) { + log.error("Savings daily interest: could not refresh the ledger for account {} after a balance change; the " + + "nightly run will pick it up", savingsAccountId, e); + } + } + + private boolean averageDailyBalanceOf(final Number calcType) { + return calcType != null && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE; + } + + /** The balance and running interest the ledger would hold on the eve of its first row. */ + private record Backfill(BigDecimal closingBalance, BigDecimal cumulativeInterest) {} + + /** + * Replays the days between a cycle's start and the first day the ledger records, so an account + * whose ledger opens mid-cycle carries in the interest it has already earned. + * + *

+ * Without this the first row starts from zero on both counts, and every day from cycleStart up to + * the day before is lost — the member is credited for one day of a cycle they have been in for + * weeks. It is only reachable on an account's very first row: once the ledger has a row, the chain + * carries itself forward. + * + *

+ * The replay walks the account's own transactions, so it reconstructs the same daily closing + * balances the nightly run would have written, including days the balance changed. Days before the + * account's activation cannot earn and are not walked — cycleStart is already clamped to activation + * by the caller. Reversed transactions are excluded exactly as {@link #dayMovement} excludes them. + * + * @return the closing balance and cumulative interest as at {@code upTo.minusDays(1)} + */ + private Backfill backfillFromCycleStart(final Long accountId, final LocalDate cycleStart, final LocalDate upTo, + final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) { + + if (!cycleStart.isBefore(upTo)) { + return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO); + } + + // Everything on or before the eve of the ledger's first day, keyed by day. The balance before + // cycleStart is carried in as the opening figure rather than replayed day by day: interest is + // earned within the cycle, but the money that earns it may have been deposited long before. + final BigDecimal openingBalance = balanceAsOf(accountId, cycleStart.minusDays(1)); + final Map movements = movementsBetween(accountId, cycleStart, upTo.minusDays(1)); + + BigDecimal balance = openingBalance; + BigDecimal cumulative = BigDecimal.ZERO; + BigDecimal balanceDaysTotal = BigDecimal.ZERO; + int days = 0; + + for (LocalDate day = cycleStart; day.isBefore(upTo); day = day.plusDays(1)) { + balance = balance.add(movements.getOrDefault(day, BigDecimal.ZERO)); + balanceDaysTotal = balanceDaysTotal.add(balance); + days++; + + if (!averageDailyBalance) { + final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && balance.compareTo(minBalance) < 0; + if (!belowMinimum && balance.compareTo(BigDecimal.ZERO) > 0 && rate != null + && rate.compareTo(BigDecimal.ZERO) > 0) { + cumulative = cumulative.add(balance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC)); + } + } + } + + if (averageDailyBalance) { + // The same shape the daily path uses: the cycle's average balance, carried across the days + // walked, rather than a sum of per-day figures. + final BigDecimal averageBalance = balanceDaysTotal.divide(BigDecimal.valueOf(days), MC); + final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0 + && averageBalance.compareTo(minBalance) < 0; + if (!belowMinimum && averageBalance.compareTo(BigDecimal.ZERO) > 0 && rate != null + && rate.compareTo(BigDecimal.ZERO) > 0) { + cumulative = averageBalance.multiply(rate, MC) + .divide(BigDecimal.valueOf(100L), MC) + .divide(BigDecimal.valueOf(daysInYear), MC) + .multiply(BigDecimal.valueOf(days), MC); + } + } + + return new Backfill(balance, cumulative); + } + + /** The account's balance at the close of a given day, from its transactions. */ + private BigDecimal balanceAsOf(final Long accountId, final LocalDate asOf) { + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE + WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + ELSE 0 END), 0) + FROM m_savings_account_transaction t + WHERE t.savings_account_id = ? + AND t.transaction_date <= ? + AND t.is_reversed = false + """, BigDecimal.class, accountId, asOf); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** Net movement per day across a date range, for days that had any. */ + private Map movementsBetween(final Long accountId, final LocalDate from, final LocalDate to) { + final Map byDay = new HashMap<>(); + jdbcTemplate.queryForList(""" + SELECT t.transaction_date AS d, + SUM(CASE + WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + ELSE 0 END) AS net + FROM m_savings_account_transaction t + WHERE t.savings_account_id = ? + AND t.transaction_date >= ? AND t.transaction_date <= ? + AND t.is_reversed = false + GROUP BY t.transaction_date + """, accountId, from, to) + .forEach(row -> byDay.put(((java.sql.Date) row.get("d")).toLocalDate(), (BigDecimal) row.get("net"))); + return byDay; + } + private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) { final BigDecimal movement = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 44f6c4172..561492159 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -41,19 +41,23 @@ import org.springframework.transaction.annotation.Transactional; * holds the figure that would say so. * * This posts the product's total first, into the liability the product names as its Savings Interest - * Payable, and only then pays it out to members: + * Payable, moves it to the account it is paid out of, and only then pays it to members: * *

- *   Dr  Savings Interest Paid / Interest On Savings   (expense, from the product)
- *   Cr  Savings Interest Payable                      (liability, from the product)
+ *   Dr  Interest On Savings        (expense, from the product)
+ *   Cr  Savings Interest Payable   (liability, from the product)     what the bank now owes
  *
- *   Dr  Savings Interest Payable                      (per member)
- *   Cr  Savings Control                               (per member, from the product)
+ *   Dr  Savings Interest Payable
+ *   Cr  Savings Interest Paid                                         on its way out
+ *
+ *   Dr  Savings Interest Paid      (per member)
+ *   Cr  Savings Control            (per member, from the product)     in the member's hands
  * 
* * Every account named above is read from the product's own GL configuration; none is written into - * this class. A product that configures no payable falls back to the two seeded control accounts, - * which keeps products predating that mapping posting exactly as before. + * this class. A product that names no paid account collapses the middle step - both legs resolve to + * the payable - and posts the two-legged entry it always did; one that names no payable falls back + * to its own Savings Control, since interest owed is a liability either way. * * The whole product settles inside one transaction, and the payable has to read zero before it is * allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run @@ -75,22 +79,21 @@ public class SavingsPooledInterestPostingService { private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; /** - * Reserved names in the product's own GL mappings, which is where the two legs of the interest - * bill are configured. The names are the contract between this service and the product screen — - * the GL codes behind them are the bank's choice, not ours, so nothing here names an account. + * Reserved names in the product's own GL mappings, which is where the middle two legs of the + * interest bill are configured. The names are the contract between this service and the product + * screen — the GL codes behind them are the bank's choice, not ours, so nothing here names an + * account. A product that leaves one unset falls back to another mapping the same product + * carries; no GL code is ever written into this file. */ private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable"; private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid"; - /** Fallback control accounts, used only where the product leaves the mappings unset. */ - private static final String GL_POSTING_CONTROL = "200304"; - private static final String GL_POSTED_CONTROL = "200305"; - /** m_savings_account_transaction.transaction_type_enum */ private static final int INTEREST_POSTING = 3; /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ private static final int ENTITY_TYPE_SAVINGS = 2; private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3; + private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2; private final JdbcTemplate jdbcTemplate; @@ -164,33 +167,39 @@ public class SavingsPooledInterestPostingService { largest.get("savings_account_id")); } - // The expense the bill is recognised against, and the liability it sits in until it reaches - // the members. Both come from the product: "Savings Interest Paid" if the product names one, - // otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the - // liability, falling back to the control accounts only where a product configures neither. - final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), - mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS)); - final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE); - final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL)); - final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL); + // The three accounts the bill passes through on its way to the members. Every one is read + // from the product's own configuration — the standard Interest On Savings mapping, and the + // two reserved names on the product's Accounting tab — so a bank on its own chart of + // accounts is posted to the accounts it chose. No GL code is named in this class. + // + // Where a product leaves a reserved name unset the bill still has somewhere to go: the + // payable falls back to the product's Savings Control (interest owed is a liability either + // way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and + // leaves the two-legged posting a product without these mappings has always had. + final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), + mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL)); + final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl); final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); final String currency = (String) due.get(0).get("currency_code"); final String poolReference = "SIP-" + productId + "-" + postingDate; - // Stage 1: the product's whole interest bill, recognised and parked in the payable. + // Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's + // interest is parked in the payable, where it stands as a debt to the members until it + // reaches them. journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null); + journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null); - // Stage 2: the pool moves to the account it is paid out of. + // Stage 2: the debt moves to the account the payout is made from, so what is still merely + // owed and what is on its way out are two separate balances rather than one. // - // Where the product names a single payable, both control legs resolve to it and this stage - // would debit and credit the same account for the same amount — a pair of entries that say - // nothing and clutter the ledger. Only a product still on the two distinct control accounts - // needs the transfer written. - if (!postingControlGl.equals(postedControlGl)) { - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + // Skipped where the product names no separate paid account: both legs then resolve to the + // payable and this would debit and credit the same account for the same amount — a pair of + // entries that say nothing and clutter the ledger. + if (!payableGl.equals(paidGl)) { + journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null); + journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null); } // Stage 3: out to the members, one at a time. @@ -208,7 +217,7 @@ public class SavingsPooledInterestPostingService { final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, newBalance); - journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference, + journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference, accountId); creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, accountId, transactionId); @@ -309,7 +318,14 @@ public class SavingsPooledInterestPostingService { created_on_utc, last_modified_on_utc, submitted_on_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) - """, officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount, + // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares + // CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way + // round: the member's savings-control credit was stored as a debit and the expense + // debit as a credit. Nothing downstream caught it — the run's own pooled-equals- + // distributed check compares Java values and never looks at type_enum, and migration + // 0531 disables the stock posting job, so this is the only path writing savings + // interest to the ledger and there was no correct one alongside to disagree with it. + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, debit ? "Savings interest posting" : "Savings interest posted", savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), date); @@ -325,16 +341,26 @@ public class SavingsPooledInterestPostingService { return ids.isEmpty() ? 1L : ids.get(0); } - private Long glAccountId(final String glCode) { - return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode); - } - + /** + * The GL account the product maps under one of Fineract's fixed financial account types, as set + * on the product's Accounting tab. + * + *

+ * A missing mapping is reported against the product rather than surfacing as an + * {@code EmptyResultDataAccessException} from inside a batch that has already posted other + * products — the message has to say which product to go and configure. + */ private Long mappedGlAccount(final long productId, final int financialAccountType) { - return jdbcTemplate.queryForObject(""" + final List found = jdbcTemplate.queryForList(""" SELECT gl_account_id FROM acc_product_mapping WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? LIMIT 1 """, Long.class, productId, financialAccountType); + if (found.isEmpty()) { + throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type " + + financialAccountType + ". Set the product's Accounting tab before interest can be posted for it."); + } + return found.get(0); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 45c852a78..62bacbc6c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -43,6 +43,8 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -59,6 +61,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi private final ConfigurationDomainService configurationDomainService; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final SavingsDailyInterestService savingsDailyInterestService; + private final VoucherService voucherService; @Autowired public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository, @@ -67,7 +71,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi final JournalEntryWritePlatformService journalEntryWritePlatformService, final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context, final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - final BusinessEventNotifierService businessEventNotifierService) { + final BusinessEventNotifierService businessEventNotifierService, + final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) { this.savingsAccountRepository = savingsAccountRepository; this.savingsAccountTransactionRepository = savingsAccountTransactionRepository; this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper; @@ -76,6 +81,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi this.context = context; this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository; this.businessEventNotifierService = businessEventNotifierService; + this.savingsDailyInterestService = savingsDailyInterestService; + this.voucherService = voucherService; } @Transactional @@ -148,6 +155,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill); } + // As on the deposit path: the ledger's row must follow the balance out, or the account goes on + // earning on money it no longer holds until tonight's run corrects it. + savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today); + + // The debit voucher for the withdrawal, including any withdrawal fee the posting collected — + // one slip, every leg, the way a payment voucher is written at a counter. + if (!transactionBooleanValues.isSkipAccountingPostings()) { + voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_SAVINGS_TRANSACTION, + withdrawal.getId(), context.authenticatedUser(), VoucherService.PREFIX_DEBIT); + } + businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal)); return withdrawal; } @@ -238,6 +256,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi if (!skipAccountingPostings) { postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); } + // The daily-interest ledger states the balance as at its last row, which is yesterday's until + // tonight's run. A closure raised in between reads that row, so money paid in today would earn + // nothing and money paid out would go on earning. Bringing the row up to date here keeps what + // a closure reads in step with what the account holds. + savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today); + + // The credit voucher for the deposit. Raised from the journal entries the posting just wrote, + // so a deposit that also collected a charge carries every leg on ONE slip — which is what the + // member signs and the branch files. This is the path every counter deposit takes (cashier, + // clerk, transfer in), and it raised no voucher at all before. + if (!skipAccountingPostings) { + voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_DEPOSIT, VoucherService.REF_SAVINGS_TRANSACTION, + deposit.getId(), context.authenticatedUser(), VoucherService.PREFIX_CREDIT); + } + businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit)); return deposit; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 8afd636c7..7030dd9b0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -363,9 +363,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } - // Money arriving at the counter: cash is debited and the member's savings control account - // is credited. - final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true); + // The voucher itself is raised by handleDeposit() above, off the journal entries it posted — + // one slip covering every leg, including any charge collected with the deposit. Read it back + // here only to return it to the caller. + final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId()); if (depositVoucherNo != null) { changes.put("voucherNo", depositVoucherNo); } @@ -446,9 +447,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.noteRepository.save(note); } - // Money leaving the member over the counter: their savings control account is debited and - // the till that pays it out is credited. - final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false); + // As on the deposit path: handleWithdrawal() raised the slip; this only reads it back. + final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId()); if (withdrawalVoucherNo != null) { changes.put("voucherNo", withdrawalVoucherNo); } @@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill); + // A charge taken from the balance is a journal voucher, not a debit voucher: no cash crosses + // the counter, the member's balance simply moves to an income head. A branch files these in + // the JV book, which is why the series differs from a counter withdrawal that collects a fee. + this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_CHARGE, VoucherService.REF_SAVINGS_TRANSACTION, + chargeTransaction.getId(), this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL); + return chargeTransaction; } @@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi account.escheat(appuserRepository.fetchSystemUser()); this.savingAccountRepositoryWrapper.saveAndFlush(account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); + + // A journal voucher for the balance moved to the escheat liability. Raised under the system + // user, not a branch user: this runs from the dormant-accounts batch with nobody at a + // counter, and the register has to say so. + final AppUser systemUser = appuserRepository.fetchSystemUser(); + for (final SavingsAccountTransaction transaction : account.getTransactions()) { + if (transaction.getId() != null && !existingTransactionIds.contains(transaction.getId()) + && transaction.isAmountOnHoldNotReleased() == false && transaction.isNotReversed()) { + this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_ESCHEAT, REF_SAVINGS_TRANSACTION, + transaction.getId(), systemUser, VoucherService.PREFIX_JOURNAL); + } + } } private AppUser getAppUserIfPresent() { @@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi } this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, - transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT, + transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL, List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount) .withDescription("Interest posted to " + accountNo), VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) .withDescription("Interest credited"))); + // Without this the voucher exists but nothing ties it to the transaction it paid, so + // the register cannot show the two together and the transaction shows no voucher_no. + this.voucherService.linkSavingsTransaction(REF_SAVINGS_TRANSACTION, transaction.getId(), transaction.getId()); } } catch (final RuntimeException e) { log.error("Could not raise interest vouchers for savings account {}", account.getId(), e); } } - private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount, - final boolean isDeposit) { - if (!this.voucherService.shouldRaiseVoucher()) { - return null; - } - - try { - final AppUser currentUser = this.context.authenticatedUser(); - final Map control = this.voucherService.savingsControlAccount(account.getId()); - final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); - - final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; - final String accountNo = account.getAccountNumber(); - - final VoucherLine memberSide = isDeposit - ? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount) - : VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount); - - final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount) - : VoucherLine.creditGl(null, "Cash in Hand", null, amount); - - final List lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide); - - return this.voucherService.createVoucherWithLines( - isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId, - currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines); - } catch (final RuntimeException e) { - log.error("Could not raise a voucher for savings transaction {}", transactionId, e); - return null; - } - } -} \ No newline at end of file +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 75c44c2d5..51cb1b193 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -435,7 +435,14 @@ public class VoucherVerificationApiResource { @Path("report") public String report(@QueryParam("from") final String from, @QueryParam("to") final String to, @QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType, - @QueryParam("cashOnly") final Boolean cashOnly) { + @QueryParam("cashOnly") final Boolean cashOnly, + @QueryParam("cashiersOnly") final Boolean cashiersOnlyParam, + @QueryParam("systemOnly") final Boolean systemOnlyParam) { + + // Defaults to false so an existing caller that does not send the flag keeps seeing the whole + // register; the two counter screens opt in. + final boolean cashiersOnly = Boolean.TRUE.equals(cashiersOnlyParam); + final boolean systemOnly = Boolean.TRUE.equals(systemOnlyParam); final AppUser currentUser = this.context.authenticatedUser(); final boolean seesEveryone = isCheckerRole(currentUser); @@ -457,6 +464,15 @@ public class VoucherVerificationApiResource { final StringBuilder sql = new StringBuilder(""" SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + -- The date the posting actually belongs to, which is not always the date the + -- voucher was written. A voucher's created_at is a wall-clock instant, while + -- the transaction behind it carries the business date — so a nightly run that + -- finishes after midnight stamps its vouchers with the next day although the + -- ledger sits on the day it worked on. Somebody reading this register today + -- is looking at last night's work, and needs to be told which day it is for. + COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) AS ledger_date, + (COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) + < CAST(v.created_at AS DATE)) AS posted_after_close, v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name, ver.status AS verification_status, dr.party_name AS debit_party, dr.account_no AS debit_account, @@ -465,6 +481,17 @@ public class VoucherVerificationApiResource { cash.moved AS is_cash FROM m_voucher v LEFT JOIN m_office o ON o.id = v.branch_office_id + -- Carries the business date the posting belongs to, for ledger_date above. + -- + -- Matched on either link. savings_txn_id is stamped by a second step after the + -- voucher is written, and on the interest register it is empty on every row -- + -- reference_id already holds the same transaction id, put there when the voucher + -- was raised. Joining only on savings_txn_id left the ledger date blank on + -- precisely the postings this column exists for. + LEFT JOIN m_savings_account_transaction t + ON t.id = COALESCE(v.savings_txn_id, + CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION' + THEN v.reference_id END) LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no LEFT JOIN LATERAL ( SELECT party_name, account_no FROM m_voucher_line @@ -537,6 +564,38 @@ public class VoucherVerificationApiResource { sql.append(" AND v.voucher_type = ?"); args.add(voucherType.trim()); } + // Narrowed to the people who hold a drawer. + // + // These two screens are the counter's own books: what came in over it and what went out. + // Everyone else's work — a clerk raising a request, a manager approving one, interest a + // nightly run posted — is their own to account for, and each person reads it on My Vouchers. + // Mixed in here it buried the cashier rows these registers exist to reconcile, and left the + // totals disagreeing with any drawer. + // + // Read from m_cashiers rather than from a role name, because holding a drawer is what makes + // somebody a cashier — a role called "Cashier" can be granted to anybody, and somebody + // covering a drawer without that role would otherwise vanish from the register they are + // answerable for. A username is matched through the staff record the drawer belongs to. + if (cashiersOnly) { + sql.append(" AND v.created_by_username IN (SELECT u.username FROM m_cashiers c" + + " JOIN m_appuser u ON u.staff_id = c.staff_id WHERE u.username IS NOT NULL)"); + } + // Postings nobody filed a request for: interest a nightly run credited, a charge collected + // from a balance, an escheat, a journal keyed straight to the ledger. + // + // Identified by what they are not — none of the five registers a person raises through. A + // reference type is stamped at post time and cannot be granted or revoked the way a role + // can, so this holds even when a job runs as somebody's own login rather than as `system`. + // + // These have no home on any other screen: My Vouchers is scoped to the reader's own work + // and a nightly job is nobody's, and the two counter registers deliberately exclude + // everything that did not cross a drawer. Without this filter the only way to see what the + // system posted was to read the whole register and discount the rest by eye. + if (systemOnly) { + sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN (" + + "'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', 'CLERK_CLOSURE_REQUEST'," + + " 'CLERK_DISBURSEMENT_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST'))"); + } // A cashier's register is cash: what came over the counter in notes and coins. // // The test is whether a side of the voucher is the till (the `till` join above). That is @@ -580,6 +639,11 @@ public class VoucherVerificationApiResource { m.put("voucherType", r.get("voucher_type")); m.put("amount", amount); m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); + // The day the posting belongs to, and whether that differs from the day it was written. + // A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's + // work: the register has to say so, or the reader takes the timestamp for the date. + m.put("ledgerDate", r.get("ledger_date") == null ? null : r.get("ledger_date").toString()); + m.put("postedAfterClose", Boolean.TRUE.equals(r.get("posted_after_close"))); m.put("userId", r.get("created_by")); m.put("username", r.get("created_by_username")); m.put("branchName", r.get("branch_name")); @@ -607,12 +671,212 @@ public class VoucherVerificationApiResource { // from the caller's roles a second time. res.put("canSeeAllUsers", seesEveryone); res.put("cashOnly", restrictToCash); + // So the screen can say whose register it is showing rather than leaving the reader to + // wonder why their own entries are absent from it. + res.put("cashiersOnly", cashiersOnly); res.put("vouchers", out); res.put("count", out.size()); res.put("totalAmount", total); return gson.toJson(res); } + /** + * One user's day: everything they raised and everything they verified, receipts and payments + * together. + * + *

+ * The Received and Payment registers each answer "what came in / went out today", across + * everyone. This answers the other question a branch asks at a close — "what did I do today" — + * and it cannot be assembled from those two, for two reasons. A checker's work leaves no + * voucher of their own: the slip stays in the maker's name, and the only trace of the approval + * is the verification row, which the registers do not read. And a person's day is one list, not + * a receipt list plus a payment list they have to reconcile in their head. + * + *

+ * Each row therefore says which side it is (CR or DR, from the voucher's own series) and what + * the user's part in it was: + *

    + *
  • RAISED — they entered it; + *
  • VERIFIED — somebody else entered it, they checked it; + *
  • RAISED_AND_VERIFIED — both, which a small branch does legitimately (a manager posting and + * verifying their own entry), and which an auditor wants to see marked rather than hidden. + *
+ * + *

+ * Always scoped to the caller, with no username parameter — a checker looking at another + * person's day uses the register with its user filter. This screen is "my work", and a + * parameter that let it be somebody else's would make its own heading a lie. + */ + @GET + @Path("my-work") + public String myWork(@QueryParam("from") final String from, @QueryParam("to") final String to) { + + final AppUser currentUser = this.context.authenticatedUser(); + final String username = currentUser.getUsername(); + + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate fromDate = parseReportDate(from, today); + final LocalDate toDate = parseReportDate(to, today); + if (toDate.isBefore(fromDate)) { + throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid", + "The end of the period cannot fall before its start."); + } + + // A voucher qualifies on any of three counts, so the filter is an OR across them rather + // than three queries stitched together — that way a voucher the user both raised and + // actioned appears once, marked as both, instead of twice. + // + // "Approved" has two distinct meanings in this system and both belong here: + // + // - the maker-checker decision, held as manager_id/checker_id on the register's own row. + // This is the one an approver actually performs and the one they came looking for; it + // raises no voucher of its own, because the slip stays in the maker's name. + // - the physical cash check, held in m_voucher_verification, where somebody counted the + // notes against the slip. + // + // Reading only the second was the bug: a manager who approved a withdrawal all morning saw + // an empty page, because approving is not counting. The registers are unioned into one + // lookup keyed by voucher number, so each register's own approver column is read where it + // actually lives instead of being duplicated onto the voucher. + final List> rows = jdbcTemplate.queryForList(""" + WITH approvals AS ( + SELECT voucher_no, manager_id AS approver_id, manager_name AS approver_name, actioned_at + FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + ), + -- Who submitted the request the voucher belongs to. + -- + -- Needed because m_voucher.created_by does not stay with the maker: when a cashier + -- hands the cash over, updateCashier() reassigns the voucher to them so it reaches + -- the cashier's own register. That reassignment overwrites rather than adds, so the + -- clerk who raised the request disappeared from their own page the moment it was + -- paid out — the register's maker column is the only surviving record of them. + makers AS ( + SELECT voucher_no, clerk_id AS maker_id, status AS request_status + FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + ) + SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, + v.created_by_username, o.name AS branch_name, + -- Where the voucher stands. The register's own status is what a person wants: + -- PENDING, APPROVED, WITHDRAWN, REJECTED — the life of the request they + -- raised or passed. The verification status is a different question (has + -- anybody counted the notes against the slip), answered on most days by + -- nobody, so showing only that left the column blank on every row. + COALESCE(mk.request_status, ver.status) AS verification_status, + -- Whoever actioned it, whichever way it was actioned. The maker-checker + -- decision is named first: it is the one a person is answerable for. + COALESCE(ap.approver_name, ver.verified_by_name) AS verified_by_name, + COALESCE(ap.actioned_at, ver.verified_at) AS verified_at, + dr.party_name AS debit_party, dr.account_no AS debit_account, + cr.party_name AS credit_party, cr.account_no AS credit_account, + (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count, + (v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me, + (ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me + FROM m_voucher v + LEFT JOIN m_office o ON o.id = v.branch_office_id + LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no + LEFT JOIN approvals ap ON ap.voucher_no = v.voucher_no + LEFT JOIN makers mk ON mk.voucher_no = v.voucher_no + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'DR' + ORDER BY line_no LIMIT 1) dr ON TRUE + LEFT JOIN LATERAL ( + SELECT party_name, account_no FROM m_voucher_line + WHERE voucher_no = v.voucher_no AND entry_type = 'CR' + ORDER BY line_no LIMIT 1) cr ON TRUE + WHERE v.created_at >= ? AND v.created_at < ? + AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?) + ORDER BY v.created_at DESC, v.voucher_no DESC + """, username, currentUser.getId(), currentUser.getId(), currentUser.getId(), + Timestamp.valueOf(fromDate.atStartOfDay()), Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()), + username, currentUser.getId(), currentUser.getId(), currentUser.getId()); + + BigDecimal receivedTotal = BigDecimal.ZERO; + BigDecimal paymentTotal = BigDecimal.ZERO; + BigDecimal journalTotal = BigDecimal.ZERO; + int raisedCount = 0; + int verifiedCount = 0; + final List> out = new java.util.ArrayList<>(); + + for (final Map r : rows) { + final BigDecimal amount = (BigDecimal) r.get("amount"); + final String voucherNo = (String) r.get("voucher_no"); + // The series the number was issued in is the side: a register's own prefix is the one + // thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type + // would have to keep a list of types in step with the ones that issue them. + final String side = voucherNo == null ? null + : voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV"; + + if (amount != null) { + if ("CR".equals(side)) { + receivedTotal = receivedTotal.add(amount); + } else if ("DR".equals(side)) { + paymentTotal = paymentTotal.add(amount); + } else { + journalTotal = journalTotal.add(amount); + } + } + + final boolean raisedByMe = Boolean.TRUE.equals(r.get("raised_by_me")); + final boolean verifiedByMe = Boolean.TRUE.equals(r.get("verified_by_me")); + if (raisedByMe) { + raisedCount++; + } + if (verifiedByMe) { + verifiedCount++; + } + + final Map m = new LinkedHashMap<>(); + m.put("voucherNo", voucherNo); + m.put("side", side); + m.put("voucherType", r.get("voucher_type")); + m.put("amount", amount); + m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); + m.put("raisedBy", r.get("created_by_username")); + m.put("branchName", r.get("branch_name")); + m.put("verificationStatus", r.get("verification_status")); + m.put("verifiedByName", r.get("verified_by_name")); + m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); + m.put("debitParty", r.get("debit_party")); + m.put("debitAccount", r.get("debit_account")); + m.put("creditParty", r.get("credit_party")); + m.put("creditAccount", r.get("credit_account")); + final int lineCount = ((Number) r.get("line_count")).intValue(); + m.put("extraLines", Math.max(0, lineCount - 2)); + m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED"); + out.add(m); + } + + final Map res = new LinkedHashMap<>(); + res.put("from", fromDate.toString()); + res.put("to", toDate.toString()); + res.put("username", username); + res.put("displayName", currentUser.getDisplayName()); + res.put("vouchers", out); + res.put("count", out.size()); + res.put("raisedCount", raisedCount); + res.put("verifiedCount", verifiedCount); + res.put("receivedTotal", receivedTotal); + res.put("paymentTotal", paymentTotal); + res.put("journalTotal", journalTotal); + return gson.toJson(res); + } + /** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */ private static LocalDate parseReportDate(final String value, final LocalDate fallback) { if (value == null || value.isBlank()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index b07bb1061..d20398465 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -24,11 +24,14 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.useradministration.domain.AppUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -57,6 +60,8 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class VoucherService { + private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class); + public static final String TYPE_WITHDRAWAL = "WITHDRAWAL"; public static final String TYPE_DEPOSIT = "DEPOSIT"; public static final String TYPE_DISBURSEMENT = "DISBURSEMENT"; @@ -66,6 +71,8 @@ public class VoucherService { public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; + /** A share redemption approved through the clerk register, keyed by the request id. */ + public static final String REF_CLERK_SHARE_WITHDRAWAL_REQUEST = "CLERK_SHARE_WITHDRAWAL_REQUEST"; public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; /** A voucher raised directly as a journal, rather than by one of the cash registers. */ @@ -87,12 +94,20 @@ public class VoucherService { */ public static final String PREFIX_JOURNAL = "JV"; + /** A charge or fee collected from an account. */ + public static final String TYPE_CHARGE = "CHARGE"; + /** An unclaimed balance moved to the escheat liability. */ + public static final String TYPE_ESCHEAT = "ESCHEAT"; + /** A loan disbursement or repayment, keyed by its m_loan_transaction id. */ public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION"; /** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */ public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY"; + /** A savings movement, keyed by its m_savings_account_transaction id. */ + public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); private final JdbcTemplate jdbcTemplate; @@ -254,6 +269,120 @@ public class VoucherService { return voucherNo; } + /** + * Raises one voucher for one savings transaction, reading its sides from the journal entries the + * posting has already written. + * + *

+ * This is how a branch register actually works: the slip is the transaction, not the leg. A + * deposit that also collects a charge posts three or four GL legs and gets ONE credit voucher + * carrying all of them, because that is what the member signed and what the teller files. Every + * register that builds its lines by hand risks drifting from the ledger it is supposed to + * mirror; this cannot, because it reads the ledger. + * + *

+ * The series follows the transaction's own direction, the way a bank's voucher books are kept: + *

    + *
  • money in over the counter — a deposit — is a credit voucher (CR); + *
  • money out — a withdrawal, a charge collected in cash — is a debit voucher (DR); + *
  • anything that moves between accounts without cash crossing the counter — interest posted, + * a charge taken from the balance, a transfer, an escheat — is a journal voucher (JV). + *
+ * The caller passes the type and prefix because only the caller knows whether cash was involved: + * the same charge is a DR voucher at the counter and a JV when a batch job collects it. + * + *

+ * Returns null, and raises nothing, when the transaction posted no journal entries — an on-hold + * or a release moves no money and has no voucher in a real branch either. A failure is logged + * rather than thrown: the transaction and its GL entries have already committed, and losing the + * slip is worth less than rolling a member's money back. + * + * @param savingsTxnId + * the m_savings_account_transaction id; its journal entries become the voucher lines + * @return the voucher number, or null if none was raised + */ + public String createVoucherFromJournalEntries(final String voucherType, final String referenceType, final Long savingsTxnId, + final AppUser user, final String prefix) { + + if (savingsTxnId == null || !shouldRaiseVoucher()) { + return null; + } + try { + // Reversed legs are excluded: a correction posts its own reversing pair, and including + // both would net the voucher to zero and fail the balance check. + final List> rows = jdbcTemplate.queryForList(""" + SELECT je.type_enum, je.amount, je.account_id, gl.name AS gl_name, gl.gl_code, + je.description + FROM acc_gl_journal_entry je + JOIN acc_gl_account gl ON gl.id = je.account_id + WHERE je.savings_transaction_id = ? + AND (je.reversed IS NULL OR je.reversed = false) + ORDER BY je.type_enum DESC, je.id + """, savingsTxnId); + if (rows.isEmpty()) { + return null; + } + + final Map party = savingsPartyForTransaction(savingsTxnId); + final String partyName = (String) party.get("party_name"); + final String accountNo = (String) party.get("account_no"); + final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue(); + + final List lines = new ArrayList<>(); + for (final Map row : rows) { + final Long glId = row.get("account_id") == null ? null : ((Number) row.get("account_id")).longValue(); + final BigDecimal amount = (BigDecimal) row.get("amount"); + // The member's own side is named after the member; every other leg is named after + // its GL account, which is what a voucher shows for an income or expense head. + final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId); + final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name"); + final String ref = isMemberSide ? accountNo : (String) row.get("gl_code"); + + // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. + final int typeEnum = ((Number) row.get("type_enum")).intValue(); + VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount) + : VoucherLine.creditGl(glId, name, ref, amount); + final String description = (String) row.get("description"); + lines.add(description == null ? line : line.withDescription(description)); + } + + final String voucherNo = createVoucherWithLines(voucherType, referenceType, savingsTxnId, user, prefix, lines); + linkSavingsTransaction(referenceType, savingsTxnId, savingsTxnId); + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for savings transaction {}", savingsTxnId, e); + return null; + } + } + + /** The voucher already raised against a savings transaction, if any. */ + public String findVoucherNoForSavingsTransaction(final Long savingsTxnId) { + if (savingsTxnId == null) { + return null; + } + return jdbcTemplate + .queryForList("SELECT voucher_no FROM m_voucher WHERE savings_txn_id = ? ORDER BY id DESC LIMIT 1", String.class, + savingsTxnId) + .stream().findFirst().orElse(null); + } + + /** The member and account a savings transaction belongs to, plus the product's control GL. */ + private Map savingsPartyForTransaction(final Long savingsTxnId) { + return jdbcTemplate.queryForList(""" + SELECT COALESCE(c.display_name, g.display_name) AS party_name, + sa.account_no, + apm.gl_account_id AS control_gl_id + 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 m_group g ON g.id = sa.group_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 = ? + LIMIT 1 + """, savingsTxnId).stream().findFirst().orElse(Map.of()); + } + /** * Checks that the lines form a voucher, and returns the value of one side. * diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index e39a6cdd6..70fbe79ba 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 09f6075d8..518b7221b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -595,4 +595,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml new file mode 100644 index 000000000..b7b61a407 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0533_backfill_savings_daily_interest_from_cycle_start.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + 0 + AND COALESCE(sp.interest_calculation_type_enum, 1) <> 2 + ), + replayed AS ( + SELECT t.id, + t.daily_interest, + SUM( + CASE + WHEN bal.balance > 0 + AND (t.min_balance IS NULL OR t.min_balance <= 0 OR bal.balance >= t.min_balance) + THEN bal.balance * t.rate / 100.0 / t.days_in_year + ELSE 0 + END + ) AS backfilled + FROM target t + CROSS JOIN LATERAL generate_series(t.cycle_start_date, t.balance_date - 1, INTERVAL '1 day') AS gs(day) + CROSS JOIN LATERAL ( + SELECT COALESCE(SUM(CASE + WHEN tr.transaction_type_enum IN (1, 3, 8) THEN tr.amount + WHEN tr.transaction_type_enum IN (2, 4, 5, 7) THEN -tr.amount + ELSE 0 END), 0) AS balance + FROM m_savings_account_transaction tr + WHERE tr.savings_account_id = t.savings_account_id + AND tr.transaction_date <= gs.day::date + AND tr.is_reversed = false + ) AS bal + GROUP BY t.id, t.daily_interest + ) + UPDATE m_savings_daily_interest d + SET cycle_cumulative_interest = r.backfilled + r.daily_interest + FROM replayed r + WHERE d.id = r.id + AND r.backfilled > 0; + ]]> + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml new file mode 100644 index 000000000..ffdc8e618 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0534_enable_business_date_advance_jobs.xml @@ -0,0 +1,75 @@ + + + + + + + + + + name = 'Increase Business Date by 1 day' + + + + + + + + name = 'Increase COB Date by 1 day' + + + + + + + UPDATE m_business_date + SET date = CURRENT_DATE + WHERE type IN ('BUSINESS_DATE', 'COB_DATE') + AND date < CURRENT_DATE; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml new file mode 100644 index 000000000..b28b4c8b4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Sync Business Date To System Date' + + + + + + + + + + + + + + + + + + + + + + + name = 'Sync Business Date To System Date' AND node_id IS NULL + + + + + + + + name IN ('Increase Business Date by 1 day', 'Increase COB Date by 1 day') + + + + + + + UPDATE m_business_date + SET date = CURRENT_DATE + WHERE type IN ('BUSINESS_DATE', 'COB_DATE') + AND date < CURRENT_DATE; + + + + diff --git a/set-business-date.ps1 b/set-business-date.ps1 new file mode 100644 index 000000000..45cf1f9ba --- /dev/null +++ b/set-business-date.ps1 @@ -0,0 +1,103 @@ +# Sets the CBS business date - the date Fineract stamps on every transaction. +# +# The business date is NOT the computer clock. It lives in m_business_date and only moves when +# something advances it, so it drifts behind real time and transactions keep landing on the old day. +# That drift is what makes a Demand Draft dated today look like a future date, and what stops the +# day's interest from accruing. +# +# Usage (from the backend folder): +# .\set-business-date.ps1 # today +# .\set-business-date.ps1 2026-09-10 # a specific day, for testing +# .\set-business-date.ps1 +1 # move forward one day +# .\set-business-date.ps1 -1 # move back one day +# .\set-business-date.ps1 -Show # just show what it is now +# +# Restart is NOT needed: the date is read per request. + +param( + [Parameter(Position = 0)] + [string]$Date = '', + + [switch]$Show +) + +$ErrorActionPreference = 'Stop' + +# Same defaults as application.properties; override with env vars if your setup differs. +$PgHost = if ($env:FINERACT_DEFAULT_TENANTDB_HOSTNAME) { $env:FINERACT_DEFAULT_TENANTDB_HOSTNAME } else { 'localhost' } +$PgPort = if ($env:FINERACT_DEFAULT_TENANTDB_PORT) { $env:FINERACT_DEFAULT_TENANTDB_PORT } else { '5432' } +$PgUser = if ($env:FINERACT_DEFAULT_TENANTDB_UID) { $env:FINERACT_DEFAULT_TENANTDB_UID } else { 'postgres' } +$PgDb = if ($env:FINERACT_DEFAULT_TENANTDB_NAME) { $env:FINERACT_DEFAULT_TENANTDB_NAME } else { 'fineract_default' } + +if (-not $env:PGPASSWORD) { + if ($env:FINERACT_DEFAULT_TENANTDB_PWD) { + $env:PGPASSWORD = $env:FINERACT_DEFAULT_TENANTDB_PWD + } else { + Write-Host "Set PGPASSWORD (or FINERACT_DEFAULT_TENANTDB_PWD) first:" -ForegroundColor Yellow + Write-Host ' $env:PGPASSWORD = "your-db-password"' -ForegroundColor Yellow + exit 1 + } +} + +$psql = Get-Command psql -ErrorAction SilentlyContinue +if ($psql) { + $psqlExe = $psql.Source +} else { + $psqlExe = Get-ChildItem 'C:\Program Files\PostgreSQL\*\bin\psql.exe' -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $psqlExe) { + Write-Host "psql.exe not found. Install the PostgreSQL client or add it to PATH." -ForegroundColor Red + exit 1 +} + +function Invoke-Psql([string]$Sql) { + & $psqlExe -h $PgHost -p $PgPort -U $PgUser -d $PgDb -A -F'|' -t -c $Sql + if ($LASTEXITCODE -ne 0) { throw "psql failed (exit $LASTEXITCODE)" } +} + +function Show-Current { + $rows = Invoke-Psql "SELECT type || ' = ' || date FROM m_business_date ORDER BY type;" + $rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object { Write-Host " $($_.Trim())" } + + $enabled = (Invoke-Psql "SELECT enabled FROM c_configuration WHERE name = 'enable-business-date';") -join '' + if ($enabled.Trim() -ne 't') { + Write-Host " (enable-business-date is OFF, so the real clock is used and this date is ignored)" -ForegroundColor DarkGray + } +} + +if ($Show -or $Date -eq '-Show') { + Write-Host "`nCurrent business date:" -ForegroundColor Cyan + Show-Current + Write-Host '' + exit 0 +} + +# Work out the target date. +if ($Date -eq '') { + $targetSql = 'CURRENT_DATE' + $label = "today ($(Get-Date -Format 'yyyy-MM-dd'))" +} elseif ($Date -match '^[+-][0-9]+$') { + $days = [int]$Date + $targetSql = "(SELECT date + $days FROM m_business_date WHERE type = 'BUSINESS_DATE')" + $label = "$(if ($days -ge 0) { "+$days" } else { $days }) day(s) from the current business date" +} elseif ($Date -match '^\d{4}-\d{2}-\d{2}$') { + try { [datetime]::ParseExact($Date, 'yyyy-MM-dd', $null) | Out-Null } + catch { Write-Host "Not a real date: $Date" -ForegroundColor Red; exit 1 } + $targetSql = "DATE '$Date'" + $label = $Date +} else { + Write-Host "Bad argument: '$Date'. Use yyyy-MM-dd, +N, -N, or nothing for today." -ForegroundColor Red + exit 1 +} + +Write-Host "`nBefore:" -ForegroundColor Cyan +Show-Current + +# COB_DATE moves with BUSINESS_DATE: leaving it behind makes close-of-business work on a day that +# has already passed, which reads as a stuck batch rather than a date setting. +Invoke-Psql "UPDATE m_business_date SET date = $targetSql WHERE type IN ('BUSINESS_DATE', 'COB_DATE');" | Out-Null + +Write-Host "`nAfter ($label):" -ForegroundColor Green +Show-Current +Write-Host "`nNo restart needed - the next request picks this up.`n" -ForegroundColor DarkGray From 05bceea2d4f0b2e4212383aad8f6420a77a255e6 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 8 Sep 2026 15:16:07 +0530 Subject: [PATCH 36/55] feat(office): maker-checker delete for offices; fix DB password leak, migration ordering, job id drift Adds the missing DELETE /offices/{officeId} endpoint -- the UI's "Remove" action called it already, but no backend route existed, so every attempt failed with a generic error regardless of how empty the office was. Wired the standard command-handler chain (CommandWrapperBuilder.deleteOffice -> DeleteOfficeCommandHandler -> OfficeWritePlatformService.deleteOffice), which pre-checks sub-offices/staff/clients/groups/users before deleting, and catches any remaining FK violation on delete itself. The new DELETE_OFFICE permission (0537) is seeded maker-checker-enabled (0538), since this app runs with the global maker-checker switch on and office deletion is irreversible -- 0538 is a separate changeset rather than 0537 itself because 0537 had already executed on local tenants by the time that requirement surfaced, and editing an already-run changeset's content breaks Liquibase's checksum validation on every tenant that ran it. Also bundles a few DB/startup fixes found while getting this feature running across local and a remote environment: - application.properties: restores the tenant DB password default that a teammate's own local password had overwritten in a previous push, and adds server.error.include-message=always so a servlet-level rejection (e.g. TenantAwareBasicAuthenticationFilter on a missing/invalid Fineract-Platform-TenantId header) carries a real message instead of a bare {timestamp,status,error,path}. - changelog-tenant.xml / final-changelog-tenant.xml: moves the 0517/0518 table-creation includes ahead of 0525-0527/0533, which read/write those tables but previously ran first per db.changelog-master.xml's include order -- caused "relation ... does not exist" on any tenant reaching that point for the first time. - 0536_sync_job_identity_sequence.xml: job.id is GENERATED BY DEFAULT AS IDENTITY; explicit-id job-seed inserts never advanced the backing sequence, so a later identity-generated insert could collide with an earlier explicit id ("duplicate key value violates ... job_pkey"). - ClientAllAccountsApiResource: NotFoundException(String) only sets the exception's own message for logs, it never reaches the response body, so a "client not found" 404 always came back empty; now built as a proper JSON error body. Also collapsed the message/defaultUserMessage/ errors[0].defaultUserMessage triple down to one message field -- this endpoint never raises more than one error at a time, so the repetition added nothing. Co-Authored-By: Claude Sonnet 5 --- .../service/CommandWrapperBuilder.java | 8 ++ .../domain/OfficeRepositoryWrapper.java | 11 +++ .../office/api/OfficesApiResource.java | 15 ++++ .../handler/DeleteOfficeCommandHandler.java | 47 ++++++++++++ .../service/OfficeWritePlatformService.java | 2 + ...WritePlatformServiceJpaRepositoryImpl.java | 75 +++++++++++++++++++ .../OrganisationOfficeConfiguration.java | 4 +- .../api/ClientAllAccountsApiResource.java | 27 ++++++- .../src/main/resources/application.properties | 12 ++- .../db/changelog/tenant/changelog-tenant.xml | 13 ++++ .../tenant/final-changelog-tenant.xml | 14 +++- .../parts/0536_sync_job_identity_sequence.xml | 30 ++++++++ .../0537_seed_delete_office_permission.xml | 51 +++++++++++++ 13 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index a8443f749..af0fb4ddd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -664,6 +664,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder deleteOffice(final Long officeId) { + this.actionName = "DELETE"; + this.entityName = "OFFICE"; + this.entityId = officeId; + this.href = "/offices/" + officeId; + return this; + } + public CommandWrapperBuilder createOfficeTransaction() { this.actionName = "CREATE"; this.entityName = "OFFICETRANSACTION"; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java b/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java index 325cc5cd2..2c437fdcd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/office/domain/OfficeRepositoryWrapper.java @@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper { public void delete(final Office entity) { this.repository.delete(entity); } + + /** + * Deletes and flushes in the same call, so a foreign-key violation the caller did not already check + * for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long + * tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather + * than at transaction-commit time where it can no longer be turned into a clean error message. + */ + public void deleteAndFlush(final Office entity) { + this.repository.delete(entity); + this.repository.flush(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java index 341754aac..1d1c4c0d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/api/OfficesApiResource.java @@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; @@ -200,6 +201,20 @@ public class OfficesApiResource { return toApiJsonSerializer.serialize(result); } + @DELETE + @Path("{officeId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.") + @ApiResponse(responseCode = "200", description = "OK") + public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .deleteOffice(officeId) // + .build(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + return toApiJsonSerializer.serialize(result); + } + @PUT @Path("/external-id/{externalId}") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java new file mode 100644 index 000000000..009fb1158 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/handler/DeleteOfficeCommandHandler.java @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.office.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.office.service.OfficeWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "OFFICE", action = "DELETE") +public class DeleteOfficeCommandHandler implements NewCommandSourceHandler { + + private final OfficeWritePlatformService writePlatformService; + + @Autowired + public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + return this.writePlatformService.deleteOffice(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java index 5cf0fbd8a..d90e3f28c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformService.java @@ -27,6 +27,8 @@ public interface OfficeWritePlatformService { CommandProcessingResult updateOffice(Long officeId, JsonCommand command); + CommandProcessingResult deleteOffice(Long officeId, JsonCommand command); + CommandProcessingResult officeTransaction(JsonCommand command); CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command); diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java index edc4180c1..bfab9ce27 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/service/OfficeWritePlatformServiceJpaRepositoryImpl.java @@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.transaction.annotation.Transactional; @@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP private final OfficeRepositoryWrapper officeRepositoryWrapper; private final OfficeTransactionRepository officeTransactionRepository; private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository; + // Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back + // to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a + // repository for every one of them just to count rows would be a lot of new wiring for a check + // that is fundamentally "does anything point at this row". A handful of raw counts up front cover + // the cases a user can actually resolve themselves (move the clients, reassign the staff); the + // long tail that is left is caught by the try/catch around the delete itself. + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP } } + /** + * Deletes an office outright -- distinct from every other office command, which only ever changes + * status or fields. There is no "closed" state for an office the way there is for a client or a + * savings account, and no command handler ever implemented this, so the UI's "Remove" action always + * failed with a generic error no matter how empty the office was; the endpoint it called never + * existed. + * + * Checked up front, in the order a user could actually act on them: sub-offices first (the deepest + * structural block), then staff, clients and groups (the day-to-day reason an office still has + * anything in it), then any AppUser still homed there. Everything else that references m_office -- + * GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings + * transactions, and more -- is not spelled out here one table at a time; a real foreign-key + * violation on the delete itself is caught below and turned into one clear message instead. An + * office with real banking history behind it is not something this should make easy to remove either + * way, checked up front or not. + */ + @Transactional + @Override + @Caching(evict = { + @CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"), + @CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"), + @CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") }) + public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) { + + this.context.authenticatedUser(); + + final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices", + "Move or remove its sub-offices before deleting this office."); + requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members", + "Reassign its staff to another office before deleting this office."); + requireNoneReference(officeId, "m_client", "office_id", "client", "clients", + "Transfer its clients to another office before deleting this office."); + requireNoneReference(officeId, "m_group", "office_id", "group", "groups", + "Transfer its groups to another office before deleting this office."); + requireNoneReference(officeId, "m_appuser", "office_id", "user", "users", + "Reassign the users homed at this office before deleting it."); + + try { + this.officeRepositoryWrapper.deleteAndFlush(office); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references", + "This office cannot be deleted: other records (accounts, transactions or history) still reference it."); + } catch (final PersistenceException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references", + "This office cannot be deleted: other records (accounts, transactions or history) still reference it."); + } + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(officeId) // + .withOfficeId(officeId) // + .build(); + } + + private void requireNoneReference(final Long officeId, final String table, final String column, final String singular, + final String plural, final String remedy) { + final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class, + officeId); + if (count != null && count > 0) { + final String noun = count == 1 ? singular : plural; + throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced", + "Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId); + } + } + @Transactional @Override public CommandProcessingResult officeTransaction(final JsonCommand command) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java index e07746e6c..c5fc22384 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/office/starter/OrganisationOfficeConfiguration.java @@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration { OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer, OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer, OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository, - ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) { + ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) { return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository, - applicationCurrencyRepository); + applicationCurrencyRepository, jdbcTemplate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java index 6391f8268..e7c7d6c37 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java @@ -25,12 +25,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; -import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; @@ -234,11 +235,33 @@ public class ClientAllAccountsApiResource { return client; } - throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '" + throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '" + tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the " + "Fineract-Platform-TenantId header -- this identifier may belong to a different bank."); } + /** + * Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*} + * exception: {@code NotFoundException(String)} only sets the Java exception's own message (for + * logs), it never puts that text on the wire -- no exception mapper in this app converts a plain + * NotFoundException's message into the response body, so the caller always got + * {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message + * passed to the constructor was. + * + * One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0] + * .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape + * exists there to line up with Fineract's own multi-field validation errors, where {@code errors} + * really does carry one entry per invalid parameter. This endpoint never raises more than one + * error at a time, so the same sentence repeated three times said nothing extra, only made the + * body longer. + */ + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("httpStatusCode", httpStatus); + body.addProperty("message", message); + return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + private String resolveClientIp(final HttpServletRequest request) { for (final String header : IP_HEADER_CANDIDATES) { final String value = request.getHeader(header); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 70fbe79ba..108e7421a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -384,6 +384,14 @@ server.forward-headers-strategy=framework server.port=${FINERACT_SERVER_PORT:8443} server.servlet.context-path=${FINERACT_SERVER_SERVLET_CONTEXT_PATH:/fineract-provider} server.compression.enabled=${FINERACT_SERVER_COMPRESSION_ENABLED:true} +# Spring Boot strips the "message" field from a servlet-container-level error response by default +# (server.error.include-message defaults to "never"), so any rejection that happens before a request +# reaches Jersey -- most commonly TenantAwareBasicAuthenticationFilter's response.sendError() when +# Fineract-Platform-TenantId is missing or invalid -- rendered as a bare {timestamp,status,error,path} +# with no indication of what was actually wrong. Every other error path in this app (JAX-RS's own +# exception mappers) already carries a message; this makes the pre-Jersey filter layer consistent +# with that instead of the framework default. +server.error.include-message=always server.ssl.enabled=${FINERACT_SERVER_SSL_ENABLED:true} server.ssl.protocol=TLS @@ -407,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 518b7221b..d0c837041 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -587,6 +587,19 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 5c0ea6560..06323958f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -43,8 +43,16 @@ - - + @@ -54,4 +62,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml new file mode 100644 index 000000000..2f6589894 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0536_sync_job_identity_sequence.xml @@ -0,0 +1,30 @@ + + + + + + + SELECT setval(pg_get_serial_sequence('job', 'id'), COALESCE((SELECT MAX(id) FROM job), 1), true); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml new file mode 100644 index 000000000..295fd1f91 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0537_seed_delete_office_permission.xml @@ -0,0 +1,51 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE' + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DELETE_OFFICE' AND can_maker_checker = false + + + + code = 'DELETE_OFFICE' + + + + From 518b4d4afc2b6d288f68280311d0a379b0e17f4f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 9 Sep 2026 13:00:12 +0530 Subject: [PATCH 37/55] feat(client-accounts): active accounts only, drop balances/shares, add branch and bank details Adds branch pincode (new column + API + migration 0538) since it was missing. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/ClientAllAccountsApiResource.java | 213 +++++++++++------- .../api/BranchProfileApiResource.java | 14 +- .../tenant/final-changelog-tenant.xml | 1 + .../tenant/parts/0538_add_branch_pincode.xml | 22 ++ 4 files changed, 163 insertions(+), 87 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java index e7c7d6c37..67b91199b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientAllAccountsApiResource.java @@ -32,7 +32,6 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; @@ -48,28 +47,44 @@ import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredential import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; -import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData; -import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations; +import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Component; /** - * A single client's full deposit + share picture, looked up by client number OR mobile number (the - * path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather - * than the internal client id -- these are what a counter clerk has on the slip, or what a third-party - * caller was given, respectively, so no separate client-search step is needed before this call. Built - * so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only - * queries, on the theory that this is exactly the kind of check a teller or manager runs right before - * approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here" - * question this endpoint answers has to come back before that decision is made, not after. + * A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share + * accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number + * (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) + * rather than the internal client id -- these are what a counter clerk has on the slip, or what a + * third-party caller was given, respectively, so no separate client-search step is needed before this + * call. Built so the whole thing, including the client lookup itself, resolves in a small handful of + * index-scan-only queries. + * + * Deliberately narrow on purpose: + *

    + *
  • Only ACTIVE accounts (status_enum 300) are returned -- a closed, pending, or rejected account is + * not part of "what does this client currently hold", which is the question this endpoint answers.
  • + *
  • No balance/currentValue/onHoldFunds figures at all, for any account type -- this is an account + * listing, not a balance enquiry; a caller that needs the money figure is expected to hit the + * account-specific balance endpoint for it (kept as a smaller, separate, cacheable concern rather than + * mixed into every listing response).
  • + *
  • Share accounts are excluded entirely -- they carry no balance in the deposit sense (approved + * shares x unit price is a valuation, not a balance) and don't belong in a "deposit accounts" listing.
  • + *
+ * + * Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations + * 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see + * {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank + * is this client's account actually at" (for a passbook header, a cheque book request, a KYC document) + * would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and + * {@code /v1/institutionprofile}. * * Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData} * (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to * m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this - * caller doesn't need -- account number, product, status and the current balance/share value is the - * whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query - * engine. + * caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different + * query engine. * * Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as * they do every other endpoint; a third-party integration that should never see a Fineract username or @@ -81,7 +96,7 @@ import org.springframework.stereotype.Component; */ @Path("/v1/clientaccounts") @Component -@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.") +@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.") @RequiredArgsConstructor public class ClientAllAccountsApiResource { @@ -96,44 +111,56 @@ public class ClientAllAccountsApiResource { // that unrelated feature toggle is on. private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; - // One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum), - // CD (m_current_account, its own table -- current accounts were never folded into - // m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same - // split), and Share (m_share_account). Every leg filters on client_id, which is indexed on - // all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an + // ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses + // the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the + // same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD + // (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table -- + // current accounts were never folded into m_savings_account here). Every leg filters on + // client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- an // index-only lookup per leg, not a scan. private static final String ACCOUNTS_SQL = """ SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id, sa.status_enum AS status_enum, sa.currency_code, sa.product_id, p.name AS product_name, p.short_name AS short_product_name, - sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold, - NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price, sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date FROM m_savings_account sa JOIN m_savings_product p ON p.id = sa.product_id - WHERE sa.client_id = ? + WHERE sa.client_id = ? AND sa.status_enum = 300 UNION ALL SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id, ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name, - ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0), - NULL::bigint, NULL::bigint, NULL::numeric(10,2), ca.submittedon_date, ca.activatedon_date FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id - WHERE ca.client_id = ? + WHERE ca.client_id = ? AND ca.status_enum = 300 + """; - UNION ALL + // Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a + // caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook + // header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet + // (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields + // just come back null. + private static final String BRANCH_SQL = """ + SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address, + p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email + FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id + WHERE o.id = ? + """; - SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id, - shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name, - NULL::numeric(19,6), NULL::numeric(19,6), - shr.total_approved_shares, shr.total_pending_shares, sp.unit_price, - shr.submitted_date, shr.activated_date - FROM m_share_account shr - JOIN m_share_product sp ON sp.id = shr.product_id - WHERE shr.client_id = ? + // The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row, + // id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a + // payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's + // IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative + // block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and + // the created/updated audit columns, which are an internal concern of the Bank Profile screen, not + // this endpoint's caller. + private static final String BANK_SQL = """ + SELECT bank_name, legal_name, short_name, bank_code, swift_bic, + address_line1, address_line2, city, district, state, pincode, + contact_phone, contact_email, customer_care_number, website + FROM m_institution_profile WHERE id = 1 """; @GET @@ -157,22 +184,18 @@ public class ClientAllAccountsApiResource { final Map client = resolveClient(identifier, tenant); final Long clientId = ((Number) client.get("id")).longValue(); - final List> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId); + final List> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId); - final Map totalsByCurrency = new LinkedHashMap<>(); final Map countByType = new LinkedHashMap<>(); for (final Map row : rows) { final String accountType = (String) row.get("accountType"); countByType.merge(accountType, 1, Integer::sum); - - final Object valueObj = row.get("currentValue"); - if (valueObj instanceof BigDecimal value) { - final String currency = (String) row.get("currencyCode"); - final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO); - totalsByCurrency.put(currency, running.add(value)); - } } + final Long officeId = ((Number) client.get("office_id")).longValue(); + final Map branch = resolveBranch(officeId); + final Map bank = resolveBank(); + final JsonObject res = new JsonObject(); // Which bank this response came from -- not decorative. One API key now authenticates // against every tenant (see ApiClientCredentialService), so the ONLY thing that decides @@ -188,15 +211,71 @@ public class ClientAllAccountsApiResource { res.addProperty("clientNo", (String) client.get("account_no")); res.addProperty("clientName", (String) client.get("display_name")); res.addProperty("mobileNo", (String) client.get("mobile_no")); - res.addProperty("officeId", ((Number) client.get("office_id")).longValue()); + res.addProperty("officeId", officeId); res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue()); + res.add("branch", gson.toJsonTree(branch)); + res.add("bank", gson.toJsonTree(bank)); res.addProperty("totalAccounts", rows.size()); res.add("accountsByType", gson.toJsonTree(countByType)); - res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency)); res.add("accounts", gson.toJsonTree(rows)); return gson.toJson(res); } + /** + * An office with no {@code m_office_profile} row yet (see + * {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary + * state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than + * this call failing or being skipped. + */ + private Map resolveBranch(final Long officeId) { + final Map row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId); + final Map branch = new LinkedHashMap<>(); + branch.put("officeId", officeId); + branch.put("officeName", row.get("office_name")); + branch.put("branchCode", row.get("branch_code")); + branch.put("branchType", row.get("branch_type")); + branch.put("address", row.get("address")); + branch.put("pincode", row.get("pincode")); + branch.put("ifscCode", row.get("ifsc_code")); + branch.put("micrCode", row.get("micr_code")); + branch.put("contactPhone", row.get("contact_phone")); + branch.put("contactEmail", row.get("contact_email")); + return branch; + } + + /** + * The bank running this tenant is a singleton row (id=1) that only gets created the first time + * someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists} + * -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened + * that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code + * null} in the response rather than this call failing. + */ + private Map resolveBank() { + final Map row; + try { + row = jdbcTemplate.queryForMap(BANK_SQL); + } catch (final EmptyResultDataAccessException e) { + return null; + } + final Map bank = new LinkedHashMap<>(); + bank.put("bankName", row.get("bank_name")); + bank.put("legalName", row.get("legal_name")); + bank.put("shortName", row.get("short_name")); + bank.put("bankCode", row.get("bank_code")); + bank.put("swiftBic", row.get("swift_bic")); + bank.put("addressLine1", row.get("address_line1")); + bank.put("addressLine2", row.get("address_line2")); + bank.put("city", row.get("city")); + bank.put("district", row.get("district")); + bank.put("state", row.get("state")); + bank.put("pincode", row.get("pincode")); + bank.put("contactPhone", row.get("contact_phone")); + bank.put("contactEmail", row.get("contact_email")); + bank.put("customerCareNumber", row.get("customer_care_number")); + bank.put("website", row.get("website")); + return bank; + } + /** * Resolves the path identifier as a client number first, then a mobile number -- two lookups in * the worst case, one in the common case, both on unique indexed columns @@ -275,27 +354,19 @@ public class ClientAllAccountsApiResource { } /** - * Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a - * single {@code currentValue} figure so the caller never has to know that a deposit's value lives - * in one column and a share account's lives in another (approved shares × unit price). + * Maps one UNION leg row into a flat, display-ready shape -- category label and human status only. + * No balance figure of any kind: this is an account listing, not a balance enquiry (see the class + * Javadoc). */ private static final class AccountRowMapper implements RowMapper> { @Override public Map mapRow(final ResultSet rs, final int rowNum) throws SQLException { - final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type"); + final int depositType = rs.getInt("deposit_type"); final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum"); - final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance"); - final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold"); - final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares"); - final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares"); - final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price"); - - final boolean isShare = depositType == null; - final String accountType = isShare ? "Share Account" : accountTypeFor(depositType); final Map row = new LinkedHashMap<>(); - row.put("accountType", accountType); + row.put("accountType", accountTypeFor(depositType)); row.put("accountId", rs.getLong("account_id")); row.put("accountNo", rs.getString("account_no")); row.put("externalId", rs.getString("external_id")); @@ -311,28 +382,8 @@ public class ClientAllAccountsApiResource { row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date"))); row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date"))); - if (isShare) { - final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum); - row.put("status", status); - row.put("approvedShares", approvedShares); - row.put("pendingShares", pendingShares); - row.put("unitPrice", unitPrice); - final BigDecimal shareValue = (approvedShares != null && unitPrice != null) - ? unitPrice.multiply(BigDecimal.valueOf(approvedShares)) - : null; - row.put("currentValue", shareValue); - } else { - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - row.put("status", status); - row.put("balance", balance); - row.put("onHoldFunds", onHold); - BigDecimal available = balance; - if (available != null && onHold != null) { - available = available.subtract(onHold); - } - row.put("availableBalance", available); - row.put("currentValue", balance); - } + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + row.put("status", status); return row; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java index 286c3d817..55f7dd7e1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -98,6 +98,7 @@ public class BranchProfileApiResource { final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); final String address = this.fromJsonHelper.extractStringNamed("address", root); + final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); @@ -124,22 +125,22 @@ public class BranchProfileApiResource { if (existing != null && existing > 0) { jdbcTemplate.update(""" UPDATE m_office_profile - SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?, + SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, working_hours_start = ?, working_hours_end = ?, working_days = ?, ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, updated_by_username = ?, updated_at = ? WHERE office_id = ? - """, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), Timestamp.from(Instant.now()), officeId); } else { jdbcTemplate.update(""" INSERT INTO m_office_profile - (office_id, branch_code, branch_type, address, contact_phone, contact_email, + (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, latitude, longitude, updated_by_username, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), Timestamp.from(Instant.now())); } @@ -150,7 +151,7 @@ public class BranchProfileApiResource { private String baseSql() { return """ SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date, - p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email, + p.branch_code, p.branch_type, p.address, p.pincode, p.contact_phone, p.contact_email, p.working_hours_start, p.working_hours_end, p.working_days, p.ifsc_code, p.micr_code, p.latitude, p.longitude, p.updated_by_username, p.updated_at @@ -169,6 +170,7 @@ public class BranchProfileApiResource { out.put("branchCode", row.get("branch_code")); out.put("branchType", row.get("branch_type")); out.put("address", row.get("address")); + out.put("pincode", row.get("pincode")); out.put("contactPhone", row.get("contact_phone")); out.put("contactEmail", row.get("contact_email")); out.put("workingHoursStart", row.get("working_hours_start")); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 06323958f..27d34f83c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -64,4 +64,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml new file mode 100644 index 000000000..eb5ca26d2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_add_branch_pincode.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + From 6aa655a8a34334f18ea36f56125d4198de680b8d Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 10 Sep 2026 12:36:38 +0530 Subject: [PATCH 38/55] fix(cash): correct branch-day reconciliation, voucher classification and duplicate posting bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BranchDayRegisterApiResource: - Make openDay/closeDay transactional and add an atomic closed_at guard so a double-close or a mid-write failure cannot leave the register half-updated. - Fix bookBreakdown()'s slip/closure withdrawal queries: m_clerk_withdrawal_request and m_clerk_closure_request store cashier_id as an appuser id in some registers and an m_cashiers id in others; try both via a dual EXISTS instead of assuming one, which was silently dropping real withdrawals from expected cash and producing a false "Short" on close. - Office-scope vault-in/settled-out and close-preview queries so a multi-office tenant's close no longer mixes another branch's cash into this one's. - closeDay requires closingCash and uses DateUtils.getBusinessLocalDate() for late-close detection instead of the server's own LocalDate.now(). - saveDenominationDetail() now deletes any prior snapshot for the same register/leg/teller before inserting, so a repeated save no longer sums duplicate denomination rows into the vault position. - resolveOffice() enforces office-hierarchy authorization via PlatformSecurityContext.validateAccessRights(). CashReconciliationApiResource / CashReportApiResource / DenominationBreakdownWriter: - Add a MAX_PLAUSIBLE_TXN_ID filter (consistent with the existing cash report guard) so a fabricated/oversized transaction id can no longer distort a cashier's expected or counted position. - Tolerate sub-rupee (paisa) rounding differences instead of requiring an exact amount match — Indian cash has no paisa coins, so exact-equality was rejecting correctly-counted cash. - CashReconciliationApiResource now trusts its own server-computed expected count rather than the client-submitted one when recording a reconciliation. VaultPositionService: - Only fall back to the branch-total OPENING leg when the office has exactly one teller; a multi-teller branch was getting one teller's vault entry applied to all of them. CashSettlementApiResource / CashInterCashierApiResource / CashExchangeApiResource: - Make settlement creation transactional; add a partial unique index (0540) blocking two active settlements for the same cashier/day, closing a double-submit race the application-level count check alone could not. - Validate a cash transfer against the sending cashier's actual denomination holdings and require both cashiers to be in the same office. - Default cash exchange date to the business date instead of null, and add the missing CREATE/REVERSE permission checks (0539 seeds them). CashierDepositRequestApiResource: - Namespace pending-deposit denomination rows with a +500,000,000 offset so they cannot collide with real transaction ids during approve/reject. ClerkDisbursementRequestApiResource (Disbursement/Collection/Fund Transfer/Bank Transfer register): - executeSavingsWithdrawal() was missing the withOwnVoucher() suppression its sibling methods already had, so every Collection-mode approval raised a second, orphaned voucher for the same money on top of the request's own — double-counting the figure in Received and Payments on My Vouchers. - Voucher numbering no longer hard-codes CR/DR by money direction. It now checks whether the payment type the clerk actually selected is cash (m_payment_type.is_cash_payment) and numbers into the Journal series when it is not, since a Transfer/Cheque/DD instrument never crosses the counter regardless of which GL it posts against. Bank Transfer vouchers (always account-to-account, never cash) are now unconditionally Journal too. - The voucher's TYPE column stopped being hard-coded to "DISBURSEMENT" for every request this register raises; it now stores the clerk's own request type (Disbursement/Collection/Fund Transfer/Bank Transfer) so My Vouchers shows what was actually submitted. VoucherVerificationApiResource (/my-work): - Classify a voucher's side (Received/Payment/Journal) by whether either of its lines actually touches the till (Cash in Hand or a cash-at-vault/teller GL), the same test the register listing already uses, instead of trusting the voucher number's own CR/DR prefix at face value. A Collection or Disbursement posted against a plain GL (no cash involved) was being counted as real cash received/paid; it now reads as Journal, matching what actually happened at the counter. Existing CR/DR requests with no lines yet are left alone, since there is nothing to test the till against. --- .../api/BranchDayRegisterApiResource.java | 186 +++++++++++++++--- .../api/CashExchangeApiResource.java | 19 +- .../api/CashierDepositRequestApiResource.java | 30 ++- .../api/CashInterCashierApiResource.java | 87 ++++++++ .../api/CashReconciliationApiResource.java | 46 ++++- .../cashreport/api/CashReportApiResource.java | 10 +- .../service/VaultPositionService.java | 28 ++- .../api/CashSettlementApiResource.java | 9 + .../ClerkDisbursementRequestApiResource.java | 53 ++++- .../service/DenominationBreakdownWriter.java | 33 +++- .../api/VoucherVerificationApiResource.java | 33 +++- .../tenant/final-changelog-tenant.xml | 2 + .../0539_seed_cash_exchange_permissions.xml | 62 ++++++ ...active_cash_settlement_per_cashier_day.xml | 53 +++++ 14 files changed, 591 insertions(+), 60 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_seed_cash_exchange_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0540_unique_active_cash_settlement_per_cashier_day.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index cbe7fdd6f..1006863bf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -45,6 +45,7 @@ import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; /** * Branch day register — the formal opening and closing of a branch's business day. @@ -70,6 +71,13 @@ public class BranchDayRegisterApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); + // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id + // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a + // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is + // never corrected -- without this same filter here, the close-preview's counted-in/counted-out + // figures would include that phantom amount, on top of the real cash the branch actually counted. + private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; + // ── GET /v1/branchday ── the register itself ────────────────────────────── /** @@ -183,14 +191,33 @@ public class BranchDayRegisterApiResource { // What the cashier's own denomination entries add up to for the day. Where this and the // expected figure agree, the day tallies and the closing figure is not in question. + // Scoped to this office through the officer who took the cash (appuser -> staff -> cashier -> + // teller -> office). Unscoped, a branch's own "does the physical count agree with the books" + // cross-check was being compared against every branch's counted cash at once, which makes it + // wrong at every office but the first. A row whose officer maps to no cashier is still + // counted, for the same reason as the withdrawal legs in bookBreakdown. final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination - WHERE txn_date = ? AND txn_type = 'DEPOSIT' - """, BigDecimal.class, Date.valueOf(businessDate))); + SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std + WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' + AND (std.txn_id IS NULL OR std.txn_id < ?) + AND (std.officer_id IS NULL + OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + WHERE au.id = std.officer_id) + OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = std.officer_id AND t.office_id = ?)) + """, BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination - WHERE txn_date = ? AND txn_type = 'WITHDRAWAL' - """, BigDecimal.class, Date.valueOf(businessDate))); + SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std + WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' + AND (std.txn_id IS NULL OR std.txn_id < ?) + AND (std.officer_id IS NULL + OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + WHERE au.id = std.officer_id) + OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = std.officer_id AND t.office_id = ?)) + """, BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); @@ -199,9 +226,15 @@ public class BranchDayRegisterApiResource { // ── POST /v1/branchday/open ─────────────────────────────────────────────── + // Transactional because opening a day is three separate writes -- the register row, its + // branch-total OPENING breakdown, and each teller's carried-forward OPENING_VAULT -- and a + // failure part-way through used to commit whatever had already run. A register left open with no + // OPENING_VAULT is not a cosmetic gap: the next close's carry-forward reaches past it to an older + // register instead, and the vault's opening balance silently regresses to a stale figure. @POST @Path("open") @Consumes(MediaType.APPLICATION_JSON) + @Transactional public String openDay(final String body) { final AppUser currentUser = this.context.authenticatedUser(); requireDayAuthority(currentUser); @@ -461,9 +494,15 @@ public class BranchDayRegisterApiResource { // ── POST /v1/branchday/close ────────────────────────────────────────────── + // Transactional for the same reason as openDay: the register UPDATE, the branch-total CLOSING + // breakdown and each teller's CLOSING_VAULT snapshot are separate writes. This database still + // carries the evidence of them committing independently -- a register whose closed_at stayed + // NULL while an orphaned CLOSING_VAULT snapshot survived, which later got summed alongside a + // second snapshot and doubled a vault's carried-forward opening balance. @POST @Path("close") @Consumes(MediaType.APPLICATION_JSON) + @Transactional public String closeDay(final String body) { final AppUser currentUser = this.context.authenticatedUser(); requireDayAuthority(currentUser); @@ -481,6 +520,16 @@ public class BranchDayRegisterApiResource { // counted note-by-note wins over a typed-in total. final BigDecimal closingCash = denominationTotal != null ? denominationTotal : this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root); + // A close needs a figure. Without one the row commits with closing_cash and cash_difference + // NULL, and every carry-forward lookup keys on "closing_cash IS NOT NULL" -- so the next open + // skips this register entirely and resurrects the day-before's note-for-note breakdown as + // tomorrow's opening cash, with no difference to explain and nothing on screen to suggest the + // chain broke. The UI always sends one; this endpoint is directly reachable and did not check. + if (closingCash == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.closing.cash.required", + "Record what was counted before closing business day " + businessDate + + " -- either a closing figure or a denomination breakdown."); + } final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root); @@ -577,7 +626,14 @@ public class BranchDayRegisterApiResource { // Closing a past date is allowed — otherwise a day left open could never be settled at all — // but it must say why it was left open, and it is stamped as a force close. - final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + // + // Judged against the tenant's BUSINESS date, not the wall clock: a branch that finishes + // counting at 00:20 is still closing its own current day, and stamping that as a forced late + // close (with a mandatory "why was this left open" reason) both annoys the closer and + // destroys the one distinction this register exists for -- closed that evening versus closed + // nine days later. The same method already uses getBusinessLocalDate() for its own + // arithmetic; using two different notions of "today" in one close was the inconsistency. + final LocalDate today = DateUtils.getBusinessLocalDate(); final boolean isLate = businessDate.isBefore(today); if (isLate && (forceReason == null || forceReason.isBlank())) { throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required", @@ -587,11 +643,18 @@ public class BranchDayRegisterApiResource { final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; - jdbcTemplate.update(""" + // "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 + // closing figure, closer and difference with no trace, and the two denomination writes + // interleaved into exactly the duplicate CLOSING rows saveDenominationDetail's + // delete-then-insert exists to prevent. Letting the database arbitrate makes the loser of + // that race fail cleanly instead. + final int closed = jdbcTemplate.update(""" UPDATE m_branch_day_register SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?, book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ? - WHERE office_id = ? AND business_date = ? + WHERE office_id = ? AND business_date = ? AND closed_at IS NULL """, // Forced either way: a late close, or a close over a drawer nobody counted. The reason is // stored whenever it was needed, not only when the date was in the past — a same-day @@ -601,6 +664,10 @@ public class BranchDayRegisterApiResource { bookCash, difference, isLate || forcedOverCashiers, (isLate || forcedOverCashiers) ? overrideReason : null, remarks, officeId, Date.valueOf(businessDate)); + if (closed == 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.already.closed", + "Business day " + businessDate + " is already closed."); + } final Long registerId = ((Number) found.get(0).get("id")).longValue(); saveDenominationDetail(registerId, "CLOSING", denominations); @@ -725,12 +792,30 @@ public class BranchDayRegisterApiResource { * {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier * added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see * {@link VaultPositionService} for why the two are never allowed to mix. + * + * Replaces whatever this exact (register, leg, teller) already held rather than adding to it. + * Without that, a second call for the same snapshot -- a retry, a re-submitted close after an + * earlier attempt stalled partway, {@link #snapshotVaultDenominations} running twice for the + * same close -- leaves the old rows sitting alongside the new ones. Nothing downstream + * deduplicates by denomination before summing, so a vault's carried-forward opening balance (or + * a branch's own opening/closing figure) silently doubles instead of being replaced, exactly the + * kind of self-inflicted "the vault has more cash than the whole branch's book figure" gap this + * table exists to prevent, not cause. */ private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId, final List> denominations) { if (denominations == null || denominations.isEmpty()) { return; } + // The bare "? IS NULL" comparison (no column on either side to infer a type from) makes + // PostgreSQL's JDBC driver fail the whole statement with "could not determine data type of + // parameter" -- unlike "teller_id = ?", which gets its type from the teller_id column even + // when the bound value is null. Casting to BIGINT gives the standalone parameter a type of + // its own, independent of context. + jdbcTemplate.update(""" + DELETE FROM m_branch_day_register_detail + WHERE register_id = ? AND leg = ? AND (teller_id = ? OR (teller_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + """, registerId, leg, tellerId, tellerId); for (final Map row : denominations) { final int value = (int) row.get("denominationValue"); final int count = (int) row.get("count"); @@ -939,11 +1024,32 @@ public class BranchDayRegisterApiResource { // disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on // APPROVED alone silently excluded every completed withdrawal from this total, overstating // the branch's expected cash by exactly what had actually been paid out that day. + // Scoped to this office through the cashier who paid it out. Unscoped, this summed EVERY + // branch's withdrawals into whichever branch happened to be closing: with 6-8 offices on + // these tenants, Head Office's expected cash was being reduced by cash another branch had + // paid over its own counter. + // + // Two id conventions have to be tolerated, because cashier_id here is NOT an m_cashiers id: + // these registers store the APPUSER id of the cashier who submitted (cashier_id = 4 = + // nitesh.cashier, while that user's m_cashiers row is id 1). Resolving it only as an + // m_cashiers id matched nothing and silently excluded every withdrawal from expected cash -- + // reporting a phantom shortage of exactly what had been paid out. Accepting either reading + // keeps this correct whichever convention a given register follows. + // + // A row whose cashier is not set at all is still counted, matching the "OR office_id IS NULL" + // leniency the deposits query above applies: no cashier means no branch to attribute it to, + // and dropping it silently would understate the total instead. final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request - WHERE status = 'WITHDRAWN' - AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? - """, BigDecimal.class, date)); + SELECT COALESCE(SUM(w.amount), 0) FROM m_clerk_withdrawal_request w + WHERE w.status = 'WITHDRAWN' + AND COALESCE(w.txn_date, CAST(w.submitted_at AS DATE)) = ? + AND (w.cashier_id IS NULL + OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = w.cashier_id AND t.office_id = ?) + OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.id = w.cashier_id AND t.office_id = ?)) + """, BigDecimal.class, date, officeId, officeId)); // Account closures pay cash over the same counter but live in their own register, so they // were missing from this total entirely -- the branch's expected cash stayed high by exactly @@ -955,24 +1061,36 @@ public class BranchDayRegisterApiResource { // since a closure takes nothing out of the drawer until the cashier hands it over (a // nil-balance closure never reaches a cashier at all, hence the closure_date fallback). final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(cashier_disbursed_amount), 0) FROM m_clerk_closure_request - WHERE status = 'CLOSED' - AND COALESCE(CAST(disbursed_at AS DATE), closure_date) = ? - """, BigDecimal.class, date)); + SELECT COALESCE(SUM(cr.cashier_disbursed_amount), 0) FROM m_clerk_closure_request cr + WHERE cr.status = 'CLOSED' + AND COALESCE(CAST(cr.disbursed_at AS DATE), cr.closure_date) = ? + AND (cr.cashier_id IS NULL + OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = cr.cashier_id AND t.office_id = ?) + OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.id = cr.cashier_id AND t.office_id = ?)) + """, BigDecimal.class, date, officeId, officeId)); final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals); - // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. + // Cash drawn from the vault to a cashier — more physical cash sitting in the branch. Scoped + // to this office by the vault it came out of, for the same reason as the withdrawals above. final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer - WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false - """, BigDecimal.class, date)); + SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct + WHERE ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false + AND (ct.from_teller_id IS NULL + OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = ct.from_teller_id AND t.office_id = ?)) + """, BigDecimal.class, date, officeId)); - // Cash a cashier settled back to the vault at the end of the day. + // Cash a cashier settled back to the vault at the end of the day, scoped to the vault it + // went back into. final BigDecimal settledOut = zero(jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement - WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false - """, BigDecimal.class, date)); + SELECT COALESCE(SUM(cs.settlement_amount), 0) FROM m_cash_settlement cs + WHERE cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false + AND (cs.to_teller_id IS NULL + OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = cs.to_teller_id AND t.office_id = ?)) + """, BigDecimal.class, date, officeId)); final BigDecimal expected = opening.add(deposits).subtract(withdrawals); @@ -1025,8 +1143,26 @@ public class BranchDayRegisterApiResource { cashAccountId, cashAccountId); } + /** + * A caller-supplied officeId is checked against the caller's own office hierarchy, not taken on + * trust. Without that check, any user whose role name happens to contain "clerk"/"manager"/"head + * cashier" (see {@link #canOperateDay}) could open or close ANOTHER branch's business day, and + * read its register, close preview and cash-GL balance, just by naming its id in the request -- + * the authority check confirmed they may operate A day, never WHOSE day. + * {@code validateAccessRights} is the same office-hierarchy gate the client and group write + * services already use, so a head-office user retains access to their branches' days and a + * branch user is confined to their own office and anything below it. + */ private Long resolveOffice(final Long requested, final AppUser user) { if (requested != null) { + final String hierarchy = jdbcTemplate + .query("SELECT hierarchy FROM m_office WHERE id = ?", (rs, n) -> rs.getString(1), requested).stream() + .findFirst().orElse(null); + if (hierarchy == null) { + throw new PlatformDataIntegrityException("error.msg.branchday.office.not.found", + "Office " + requested + " was not found."); + } + this.context.validateAccessRights(hierarchy); return requested; } return user.getOffice() != null ? user.getOffice().getId() : 1L; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java index 3ec138689..5741531de 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java @@ -53,15 +53,28 @@ public class CashExchangeApiResource { @POST @Transactional public String create(String body) { + // Gated like every sibling register in this flow (CREATE_CASHTRANSFER, + // CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER). Cash exchange had no check at all, so any + // authenticated user -- not just a cashier -- could write legs that move a drawer's + // denomination position. Permission seeded and granted to the existing cash-handling roles by + // migration 0539. + this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHEXCHANGE"); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null; final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString() : null; final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null; + // Defaulted, never left null: every as-of-date position query filters + // "ce.exchange_date <= CAST(? AS DATE)", which is false for NULL, so a null-dated exchange + // vanished from the cashier position and reconciliation reports while still counting in the + // undated stock query (DenominationBreakdownWriter.getCashierNetPosition). The drawer then + // read as holding the notes it had given away and none of the ones it had taken in. The + // sibling registers (cash transfer, settlement) already default the same way. final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull() ? LocalDate.parse(json.get("exchangeDate").getAsString()) - : null; + : DateUtils.getBusinessLocalDate(); 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; @@ -153,6 +166,10 @@ public class CashExchangeApiResource { @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())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 6dba8fb89..290d81092 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -77,6 +77,19 @@ public class CashierDepositRequestApiResource { // activity JsonArray, and a field of the same name reads as that variable at a glance. private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class); + // The temporary txn_id a pending deposit's denomination rows are tagged with (see + // savePendingDenomination) is this request's own id -- but m_savings_txn_denomination.txn_id has + // no reference-type discriminator, and a real m_savings_account_transaction id is drawn from the + // exact same small-integer range. Request #5 and transaction #5 coexisting is the ordinary case, + // not a rare one, so approve()'s "WHERE txn_id = " UPDATE (and reject()'s + // DELETE) would silently rewrite or erase an unrelated real transaction's own denomination + // breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no + // real transaction id will reach in this system's lifetime makes every such match unambiguous, + // without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so + // a request still sitting PENDING continues to count in cashier-position reports -- the cash is + // physically in the drawer already, approval or not. + private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L; + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -452,13 +465,15 @@ public class CashierDepositRequestApiResource { } // Denomination was already saved at submit time (cashier physically received cash then). - // Now update the temporary txn_id (request id) to the real Fineract transaction id. - // parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry). + // Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to + // the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current) + // and hex (GL journal entry). if (txnId != null) { final Long realTxnId = parseTxnIdToLong(txnId); if (realTxnId != null) { jdbcTemplate.update( - "UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id); + "UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, + id + PENDING_DEPOSIT_TXN_ID_OFFSET); } } @@ -510,9 +525,10 @@ public class CashierDepositRequestApiResource { throw apiError(409, "This deposit request was already actioned by another manager."); } - // Reverse denomination — cashier physically returned cash to customer on rejection. - // The temporary denomination records (saved at submit time with txn_id = request id) are removed. - jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id); + // Reverse denomination — cashier physically returned cash to customer on rejection. The + // temporary denomination records (saved at submit time with txn_id = offset request id -- see + // PENDING_DEPOSIT_TXN_ID_OFFSET) are removed. + jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET); return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); } @@ -875,7 +891,7 @@ public class CashierDepositRequestApiResource { denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?) """, - requestId, savingsAccountId, accountNo, clientId, denomId, + requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), cashierId, cashierUsername, paymentTypeName ); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java index 2f72e2ad2..5fccf713a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java @@ -23,12 +23,14 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository; import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -44,6 +46,10 @@ public class CashInterCashierApiResource { private final CashInterCashierDetailRepository detailRepository; private final PlatformSecurityContext context; private final CashierRoleHelper cashierRoleHelper; + private final JdbcTemplate jdbcTemplate; + // The same figure the Cashier Position screen shows, so a cashier cannot hand on notes the + // report says they are not holding -- CashSettlementApiResource depends on it the same way. + private final CashReportApiResource cashReportApiResource; private final Gson gson = new Gson(); @POST @@ -85,6 +91,21 @@ public class CashInterCashierApiResource { throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty", "At least one denomination with a positive count is required."); } + + // 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 + // for ever, on every future date: the receiver's position, reconciliation and settlement + // allowance all rise by ₹1,00,000 of notes that do not exist, and the sender's reads + // -195x₹500 with nothing to net it back. + validateCashierHoldsDenominations(fromCashierId, denominations, transferDate); + + // Both cashiers must sit under the same office. Nothing stopped a cross-branch inter-cashier + // transfer, which moves physical cash between two branches with no vault leg on either side + // -- so one branch's day-close is short and the other's is over, with no record of a transfer + // between them anywhere in either branch's register. + validateSameOffice(fromCashierId, toCashierId); + final BigDecimal total = sumTotal(denominations); CashInterCashier header = new CashInterCashier(); @@ -206,6 +227,72 @@ public class CashInterCashierApiResource { return false; } + /** + * 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. + */ + private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations, + final LocalDate transferDate) { + + final Map position = cashReportApiResource.buildPositionForCashier(fromCashierId, + transferDate.toString()); + + @SuppressWarnings("unchecked") + final List> heldRows = (List>) position.get("denominations"); + if (heldRows == null || heldRows.isEmpty()) { + return; + } + + final Map held = new java.util.HashMap<>(); + for (final Map row : heldRows) { + final Object id = row.get("denominationId"); + final Object count = row.get("count"); + if (id instanceof Number && count instanceof Number) { + held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum); + } + } + + for (final JsonElement el : denominations) { + final JsonObject item = el.getAsJsonObject(); + final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0; + if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) { + continue; + } + final long denominationId = item.get("denominationId").getAsLong(); + final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + ? item.get("denominationValue").getAsInt() : 0; + final long available = held.getOrDefault(denominationId, 0L); + if (count > available) { + throw new PlatformDataIntegrityException("error.msg.cashintercashier.insufficient.cashier.denomination", + "Cashier is not holding that much of denomination " + denominationValue + ": has " + available + + ", transferring " + count + ". Check the Cashier Position report first."); + } + } + } + + /** Both cashiers must belong to the same office, via their own teller. */ + private void validateSameOffice(final Long fromCashierId, final Long toCashierId) { + final List> offices = jdbcTemplate.queryForList(""" + SELECT c.id AS cashier_id, t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.id IN (?, ?) + """, fromCashierId, toCashierId); + if (offices.size() < 2) { + // One of them has no teller/office resolvable -- leave it to the existing party checks + // rather than inventing a new failure here. + return; + } + final Object first = offices.get(0).get("office_id"); + final Object second = offices.get(1).get("office_id"); + if (first != null && second != null && !first.equals(second)) { + throw new PlatformDataIntegrityException("error.msg.cashintercashier.cross.office", + "Both cashiers must be in the same branch. Cash moving between branches has to go through" + + " each branch's own vault, not a direct cashier-to-cashier transfer."); + } + } + private void saveDetails(Long interCashierId, JsonArray items) { for (JsonElement el : items) { JsonObject item = el.getAsJsonObject(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index cc922d638..c303214ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -47,6 +47,13 @@ public class CashReconciliationApiResource { private final JdbcTemplate jdbcTemplate; private final Gson gson = new Gson(); + // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id + // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a + // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is + // never corrected -- without this same filter here, this endpoint (unlike the cashier-position + // report) would count that phantom figure into a cashier's expected cash. + private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; + /** * GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd * Returns denomination-wise expected balance for a cashier. @@ -89,6 +96,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'DEPOSIT' " + "AND std.txn_date <= CAST(? AS DATE) " + + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " @@ -118,6 +126,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'WITHDRAWAL' " + "AND std.txn_date <= CAST(? AS DATE) " + + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " @@ -259,6 +268,14 @@ public class CashReconciliationApiResource { final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray() ? json.getAsJsonArray("denominations") : new JsonArray(); + // Expected counts come from the server's own computation, never from the request body. + // Reading the client's expectedCount made the whole reconciliation self-certifying: a cashier + // Rs 40,000 short could post expectedCount == physicalCount on every row, and the record + // stored variance 0 / status OK. That row then satisfied the mandatory reconciliation + // precondition on settlement, so the shortage never surfaced anywhere in the system. The + // physical count is the cashier's to declare; what the books expect is not. + final Map serverExpected = expectedCountsByDenomination(cashierId, reconDate); + BigDecimal expectedTotal = BigDecimal.ZERO; BigDecimal physicalTotal = BigDecimal.ZERO; List details = new ArrayList<>(); @@ -266,7 +283,10 @@ public class CashReconciliationApiResource { for (JsonElement el : denominations) { JsonObject item = el.getAsJsonObject(); int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0; - int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0; + final Long denomIdForExpected = item.has("denominationId") && !item.get("denominationId").isJsonNull() + ? item.get("denominationId").getAsLong() : null; + int expectedCount = denomIdForExpected != null + ? serverExpected.getOrDefault(denomIdForExpected, 0L).intValue() : 0; int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0; int varianceCount = physicalCount - expectedCount; BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount); @@ -348,6 +368,30 @@ public class CashReconciliationApiResource { rs.getLong("total_count") }; + /** + * The server's own expected note/coin counts for a cashier as of a date, keyed by denomination + * id. Delegates to {@link #getExpected} rather than re-deriving the eight legs: two + * implementations of "what should this drawer hold" drift apart, and the one place that matters + * is the record a settlement is later allowed on the strength of. + */ + private Map expectedCountsByDenomination(final Long cashierId, final LocalDate asOfDate) { + final Map expected = new java.util.HashMap<>(); + final JsonObject computed = JsonParser.parseString(getExpected(cashierId, asOfDate.toString())).getAsJsonObject(); + if (!computed.has("denominations") || !computed.get("denominations").isJsonArray()) { + return expected; + } + for (final JsonElement el : computed.getAsJsonArray("denominations")) { + final JsonObject row = el.getAsJsonObject(); + if (!row.has("denominationId") || row.get("denominationId").isJsonNull()) { + continue; + } + final long count = row.has("expectedCount") && !row.get("expectedCount").isJsonNull() + ? row.get("expectedCount").getAsLong() : 0L; + expected.merge(row.get("denominationId").getAsLong(), count, Long::sum); + } + return expected; + } + private void addToBalance(Map balance, List rows, int sign) { for (Object[] row : rows) { Long denomId = (Long) row[0]; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index b0094cb29..7dc86fdf6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -76,14 +76,20 @@ public class CashReportApiResource { // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven // such transactions in this database were carrying ₹1.68 lakh that never existed. // + // The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation, + // so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the + // nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed + // breakdown, and treating it as one silently dropped an entire genuine withdrawal (and + // everything counted after it) out of the cashier's position. + // // Compared per transaction inside a correlated subquery rather than as a join, so a // breakdown with no transaction behind it -- a Demand Draft keyed by its request id -- // is untouched by this test and still handled by the id-magnitude test above. + "AND NOT EXISTS (" + " SELECT 1 FROM m_savings_account_transaction mt " + " WHERE mt.id = std.txn_id" - + " AND mt.amount <> (SELECT SUM(sd.count * sd.denomination_value)" - + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) "; + + " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)" + + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) "; /** * Day book — every voucher raised on one date, in one list, newest first. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java index 1e7510f31..b0138d066 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/service/VaultPositionService.java @@ -89,14 +89,26 @@ public class VaultPositionService { """, officeId, dateFilter, tellerId); String anchorLeg = "OPENING_VAULT"; if (anchorRows.isEmpty()) { - anchorLeg = "OPENING"; - anchorRows = jdbcTemplate.queryForList(""" - SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r - WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) - AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d - WHERE d.register_id = r.id AND d.leg = 'OPENING') - ORDER BY r.business_date DESC LIMIT 1 - """, officeId, dateFilter); + // The branch-total OPENING leg mixes the vault with whatever every cashier under it + // holds, so it is only a defensible stand-in for a vault when the office has exactly one + // teller. With two, EVERY teller without a vault-scoped row of its own would anchor on + // the same office-wide breakdown, and the frontend's Branch Total -- which sums each + // teller's vault -- would report roughly twice the branch's actual cash. Worse, a close + // then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open + // carries it forward, making the phantom permanent. A second teller with no history of + // its own genuinely holds nothing, and returning no breakdown says so honestly. + final Integer tellerCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?", + Integer.class, officeId); + if (tellerCount != null && tellerCount == 1) { + anchorLeg = "OPENING"; + anchorRows = jdbcTemplate.queryForList(""" + SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r + WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE) + AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d + WHERE d.register_id = r.id AND d.leg = 'OPENING') + ORDER BY r.business_date DESC LIMIT 1 + """, officeId, dateFilter); + } } if (anchorRows.isEmpty()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 82dbae991..847ea66a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -63,7 +63,16 @@ public class CashSettlementApiResource { private final CashReportApiResource cashReportApiResource; private final Gson gson = new Gson(); + // Transactional so the reconciliation precondition and the duplicate-settlement check below are + // read in the same transaction that writes the settlement. They used to run in autocommit while + // the insert happened later inside its own TransactionTemplate, leaving a window where a + // double-clicked Submit passed both checks twice and wrote two settlements for the same + // cashier/date -- each subtracted cumulatively, so the drawer read permanently negative by the + // settlement amount. Migration 0540 adds the unique index that makes the race impossible rather + // than merely unlikely; this annotation is what stops the two halves of the check-then-act from + // being separated by another request's commit. @POST + @Transactional public String create(String body) { context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 626f6e5c0..a9b60edf5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -187,12 +187,26 @@ public class ClerkDisbursementRequestApiResource { activityLog, Timestamp.from(Instant.now()), requestType ); - // A collection pulls money into the GL, so it reads as a credit; a disbursement and a - // bank transfer both move money out of the source side, so they read as a debit. - final String voucherPrefix = "COLLECTION".equals(requestType) - ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT; + // A collection pulls money into the GL, so it reads as a credit; a disbursement moves + // money out of the source side, so it reads as a debit — but only when the TXN TP. the + // clerk actually picked is Cash. That dropdown is the real-world signal for whether notes + // cross the counter, the same way it already governs every teller screen in this system + // (isCashPayment): a clerk who selects Transfer, Cheque, DD or NEFT/RTGS is saying no cash + // moves for this request, whatever GL it happens to post against, so it belongs in the + // Journal series instead of being counted as real cash received or paid on My Vouchers. + final boolean isCashPayment = paymentTypeId != null && Boolean.TRUE.equals(jdbcTemplate.queryForObject( + "SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId)); + final String voucherPrefix = !isCashPayment ? VoucherService.PREFIX_JOURNAL + : "COLLECTION".equals(requestType) ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT; + // The voucher's own TYPE column used to be hard-coded to VoucherService.TYPE_DISBURSEMENT + // for every request this register raises, so My Vouchers showed "DISBURSEMENT" even for a + // Collection or a Fund Transfer the clerk had explicitly picked a different TYPE for on the + // entry screen — the one place this register's actual four-way distinction ought to have + // been visible was throwing it away. Stored as the clerk's own requestType instead, in the + // same words the TYPE dropdown already uses. + final String voucherTypeLabel = voucherTypeLabelFor(requestType); final String voucherNo = voucherService.createVoucher( - VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST, + voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST, newId, txnAmount, currentUser, voucherPrefix); jdbcTemplate.update( "UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId); @@ -1010,13 +1024,26 @@ public class ClerkDisbursementRequestApiResource { */ /** * Bank-transfer settlement voucher, drawn from the same central register as every other - * voucher so the two numbering schemes cannot drift apart. A settlement moves money out of - * the debited accounts, so it reads as a debit: DR/2108/0001. + * voucher so the two numbering schemes cannot drift apart. + * + * Numbered Journal rather than Debit: both sides of a bank transfer are member accounts, and + * the money never touches Cash in Hand or the vault either way (see the "no CashVault entry + * created anywhere" comments where these accounts are debited/credited directly). A DR number + * would have read as real cash paid out, double-counting the figure in My Vouchers on top of + * the day's actual Payment total. */ private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) { return voucherService.createVoucher( - VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG, - logId, amount, currentUser, VoucherService.PREFIX_DEBIT); + "Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG, + logId, amount, currentUser, VoucherService.PREFIX_JOURNAL); + } + + /** The TYPE column's label, in the same words the clerk's own TYPE dropdown uses for it. */ + private static String voucherTypeLabelFor(final String requestType) { + if ("COLLECTION".equals(requestType)) return "Collection"; + if ("BANK_TRANSFER".equals(requestType)) return "Bank Transfer"; + if ("FUND_TRANSFER".equals(requestType)) return "Fund Transfer"; + return "Disbursement"; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -1250,7 +1277,13 @@ public class ClerkDisbursementRequestApiResource { .withJson(payload.toString()) .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd); + // This disbursement/collection request already raised its own voucher at submission and + // attaches every leg to it in addDisbursementVoucherLines() after approval succeeds — without + // withOwnVoucher() the plain savings withdrawal command below raises a second, orphaned + // voucher of its own for the same money (see VoucherService.withOwnVoucher's javadoc), which + // is what was double-counting a Collection's amount in both Received and Payments on My Vouchers. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index bf72dd855..8e4c9b5fb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -40,6 +40,14 @@ public class DenominationBreakdownWriter { private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; + // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id + // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a + // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is + // never corrected -- without this same filter here, a cashier's computed stock (and the + // insufficient-stock check that guards a withdrawal against it) would count that phantom figure + // as real notes and coins in the drawer. + private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; + // ── Public API ──────────────────────────────────────────────────────────── /** @@ -126,8 +134,17 @@ public class DenominationBreakdownWriter { "Denomination breakdown validation errors exist.", errors); } - // 5. Grand total must match the transaction amount - if (expectedAmount != null && computedTotal.compareTo(expectedAmount) != 0) { + // 5. Grand total must match the transaction amount, to within a rupee. + // + // Exact equality made a whole class of legitimate transaction impossible to complete: an + // interest-bearing withdrawal or closure settles on a paisa figure (₹12,998.56), Indian cash + // has no paisa coins, so NO set of notes can ever sum to it and every breakdown the cashier + // entered was rejected. Sub-rupee is therefore accepted as the rounding it is -- the same + // tolerance CashReportApiResource's own breakdown check applies on the reporting side, so + // the two agree instead of one accepting what the other rejects. A genuinely mis-keyed + // breakdown (₹1,60,000 of notes against a ₹918 deposit) is off by far more than a rupee and + // is still refused. + if (expectedAmount != null && computedTotal.subtract(expectedAmount).abs().compareTo(BigDecimal.ONE) >= 0) { throw new PlatformDataIntegrityException( "error.msg.denomination.total.mismatch", label + " denomination breakdown total (" + computedTotal @@ -277,6 +294,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" + + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -300,6 +318,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" + + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -309,7 +328,13 @@ public class DenominationBreakdownWriter { + " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'" + " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)" + " GROUP BY ced.denomination_id" - + " ) raw GROUP BY denomination_id HAVING SUM(net_count) > 0" + // No "HAVING SUM(net_count) > 0" here: dropping the denominations that net negative + // reports MORE cash than the drawer holds, because the shortfall they represent is + // silently discarded instead of subtracted. Net -20x₹500 with +30x₹100 is -₹7,000, + // not +₹3,000 -- and this figure is what validateCashierHasTotal lets a withdrawal + // through on, so the optimistic version hands out money that is not there. It also + // disagreed with getCashierNetPosition below, which has never filtered them. + + " ) raw GROUP BY denomination_id" + ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id"; final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, @@ -390,6 +415,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" + + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -413,6 +439,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" + + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 51cb1b193..ae91fc825 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -785,7 +785,15 @@ public class VoucherVerificationApiResource { cr.party_name AS credit_party, cr.account_no AS credit_account, (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count, (v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me, - (ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me + (ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me, + -- Whether either side of the voucher is the till itself — same test as the + -- register listing below, and for the same reason: a Collection/Disbursement + -- request is numbered in the CR/DR series (it debits or credits a member's + -- own account), but when its other leg is a GL account rather than the vault, + -- no cash actually crossed the counter. Counting it under Received or Payment + -- read as double-counting the exact figure already shown on the Journal-free + -- day totals, when it was really a book entry between two ledgers. + till.touched AS till_touched FROM m_voucher v LEFT JOIN m_office o ON o.id = v.branch_office_id LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no @@ -799,6 +807,14 @@ public class VoucherVerificationApiResource { SELECT party_name, account_no FROM m_voucher_line WHERE voucher_no = v.voucher_no AND entry_type = 'CR' ORDER BY line_no LIMIT 1) cr ON TRUE + LEFT JOIN LATERAL ( + SELECT TRUE AS touched FROM m_voucher_line cl + WHERE cl.voucher_no = v.voucher_no + AND (cl.gl_account_id IN ( + SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa + WHERE fa.financial_activity_type IN (101, 102)) + OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand')) + LIMIT 1) till ON TRUE WHERE v.created_at >= ? AND v.created_at < ? AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?) ORDER BY v.created_at DESC, v.voucher_no DESC @@ -816,11 +832,23 @@ public class VoucherVerificationApiResource { for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); final String voucherNo = (String) r.get("voucher_no"); + final int lineCount = ((Number) r.get("line_count")).intValue(); // The series the number was issued in is the side: a register's own prefix is the one // thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type // would have to keep a list of types in step with the ones that issue them. - final String side = voucherNo == null ? null + final String rawSide = voucherNo == null ? null : voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV"; + // A CR/DR voucher whose lines never touch the till (the same test bookBreakdown() uses + // below) is a book entry between two ledgers that happened to be numbered in the cash + // series — a Collection/Disbursement request posted against a GL account rather than the + // vault, e.g. a savings account debited straight into a Sundry GL account. Reading its + // prefix at face value counted that figure a second time, on top of whatever Journal + // already showed, as if real cash had crossed the counter. A voucher with no lines yet + // (raised but not actioned) is left alone — there is nothing to test the till against, + // and it is still real expected cash, not yet a book entry either way. + final boolean tillTouched = Boolean.TRUE.equals(r.get("till_touched")); + final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched + ? "JV" : rawSide; if (amount != null) { if ("CR".equals(side)) { @@ -856,7 +884,6 @@ public class VoucherVerificationApiResource { m.put("debitAccount", r.get("debit_account")); m.put("creditParty", r.get("credit_party")); m.put("creditAccount", r.get("credit_account")); - final int lineCount = ((Number) r.get("line_count")).intValue(); m.put("extraLines", Math.max(0, lineCount - 2)); m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED"); out.add(m); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 27d34f83c..6b737c847 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -65,4 +65,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_seed_cash_exchange_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_seed_cash_exchange_permissions.xml new file mode 100644 index 000000000..bd5af3235 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_seed_cash_exchange_permissions.xml @@ -0,0 +1,62 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_CASHEXCHANGE' + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT DISTINCT existing.role_id, newperm.id + FROM m_role_permission existing + JOIN m_permission sibling ON sibling.id = existing.permission_id + CROSS JOIN m_permission newperm + WHERE sibling.code = 'CREATE_CASHTRANSFER' + AND newperm.code IN ('CREATE_CASHEXCHANGE', 'READ_CASHEXCHANGE', 'REVERSE_CASHEXCHANGE') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = existing.role_id AND rp.permission_id = newperm.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0540_unique_active_cash_settlement_per_cashier_day.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0540_unique_active_cash_settlement_per_cashier_day.xml new file mode 100644 index 000000000..7312f7676 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0540_unique_active_cash_settlement_per_cashier_day.xml @@ -0,0 +1,53 @@ + + + + + + + + + SELECT COUNT(*) FROM ( + SELECT from_cashier_id, settlement_date + FROM m_cash_settlement + WHERE COALESCE(is_reversed, false) = false + AND from_cashier_id IS NOT NULL + GROUP BY from_cashier_id, settlement_date + HAVING COUNT(*) > 1 + ) dupes + + + SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'm_cash_settlement' + AND indexname = 'uq_cash_settlement_active_per_cashier_day' + + + + + CREATE UNIQUE INDEX uq_cash_settlement_active_per_cashier_day + ON m_cash_settlement (from_cashier_id, settlement_date) + WHERE COALESCE(is_reversed, false) = false AND from_cashier_id IS NOT NULL + + + DROP INDEX IF EXISTS uq_cash_settlement_active_per_cashier_day + + + + From 5cc5d680f2bdf13a897f567ac02bea9732b8cda1 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 10 Sep 2026 18:27:50 +0530 Subject: [PATCH 39/55] feat: separate kyc/aml risk rules, dormancy clock, and savings daily interest fixes --- .../portfolio/client/domain/ClientKyc.java | 91 +++++-- .../client/domain/ClientRiskRule.java | 36 ++- .../service/AccountingProcessorHelper.java | 61 ++++- .../jobs/service/JobSchedulerServiceImpl.java | 17 ++ .../api/CashierDepositRequestApiResource.java | 4 + .../ClerkDisbursementRequestApiResource.java | 10 + .../ClerkWithdrawalRequestApiResource.java | 9 + .../client/data/KycRiskRuleData.java | 11 +- .../client/data/KycRiskRuleRequest.java | 6 + ...WritePlatformServiceJpaRepositoryImpl.java | 87 ++++++- .../client/service/KycRestrictionGuard.java | 201 +++++++++++++++ .../KycRiskRulesReadPlatformServiceImpl.java | 1 + .../KycRiskRulesWritePlatformServiceImpl.java | 46 +++- .../SavingsDailyInterestOrchestrator.java | 68 +++++- .../SavingsDailyInterestService.java | 23 +- .../SavingsPooledInterestPostingService.java | 203 +++++++++++----- .../api/SavingsDailyInterestApiResource.java | 30 ++- .../SavingsAccountDomainServiceJpa.java | 16 +- .../savings/service/DormancyClock.java | 63 +++++ ...DormantAccountReadPlatformServiceImpl.java | 85 ++++++- ...SavingsAccountReadPlatformServiceImpl.java | 228 ++++++++++++++++-- .../savings/starter/SavingsConfiguration.java | 8 +- .../src/main/resources/application.properties | 4 +- .../db/changelog/tenant/changelog-tenant.xml | 1 + ...7_seed_forfeit_unclaimed_dividends_job.xml | 27 +++ ...7_create_savings_daily_interest_ledger.xml | 57 +++++ .../parts/0525_savings_daily_interest_job.xml | 13 + .../0526_savings_interest_summary_view.xml | 13 + ...ngs_daily_interest_one_row_per_account.xml | 16 ++ ..._sync_business_date_to_system_date_job.xml | 54 +++++ .../0538_separate_kyc_and_aml_risk_rules.xml | 203 ++++++++++++++++ .../savings/domain/SavingsAccount.java | 123 +++++++++- .../2011_add_facility_charge_mapping.xml | 48 ++++ sql/fix_stale_kyc_exceptions.sql | 73 ++++++ 34 files changed, 1777 insertions(+), 159 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml create mode 100644 sql/fix_stale_kyc_exceptions.sql diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 1c02f0dc2..4bbbdf8cc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { this.dinVerified = false; this.isException = false; this.exceptionResolved = false; - this.initiatedOn = DateUtils.getLocalDateOfTenant(); + this.initiatedOn = DateUtils.getBusinessLocalDate(); } public static ClientKyc initiateKyc(final Client client, final JsonCommand command) { @@ -562,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { v -> this.form15Applicable = v); updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v); updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v); - updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false); - updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v); - updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false); - updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v); + // The four exception columns are derived from whether the identity checks have passed, not + // data an officer types, so they are updated without recording an overwrite. A verified + // record demands remarks for any field it already held (see + // getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so + // counting these would make closing a settled exception impossible without a justification + // for something nobody chose. That is what left clients verified on the KYC tab while the + // exceptions register still listed them as Pending. + updateDerivedBooleanField(command, "isException", this.isException, actualChanges, + v -> this.isException = v != null ? v : false); + updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges, + v -> this.exceptionReason = v); + updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, + v -> this.exceptionResolved = v != null ? v : false); + updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, + v -> this.exceptionResolvedOn = v); updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false); updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false); updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false); @@ -620,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { * populated is the record being completed rather than altered, so it is deliberately not * recorded — see {@link #getFieldsOverwrittenByLastUpdate()}. */ + /* + * Derived-field variants of the update helpers above. + * + * Identical, minus the recordOverwrite call: these columns are computed from whether the + * identity checks have passed rather than entered by an officer, so replacing one is not the + * kind of change a verified record demands a justification for. They still report into + * `changes`, so the update is persisted and audited like any other. + */ + + private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current, + final Map changes, final java.util.function.Consumer setter) { + if (command.parameterExists(param)) { + final Boolean newValue = command.booleanObjectValueOfParameterNamed(param); + if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) { + changes.put(param, newValue); + setter.accept(newValue); + } + } + } + + private void updateDerivedStringField(final JsonCommand command, final String param, final String current, + final Map changes, final java.util.function.Consumer setter) { + if (command.isChangeInStringParameterNamed(param, current)) { + final String newValue = command.stringValueOfParameterNamed(param); + changes.put(param, newValue); + setter.accept(StringUtils.defaultIfEmpty(newValue, null)); + } + } + + private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current, + final Map changes, final java.util.function.Consumer setter) { + if (command.isChangeInLocalDateParameterNamed(param, current)) { + final LocalDate newValue = command.localDateValueOfParameterNamed(param); + changes.put(param, newValue); + setter.accept(newValue); + } + } + private void recordOverwrite(final String param, final Object currentValue) { final boolean wasPopulated = currentValue instanceof String text ? StringUtils.isNotBlank(text) @@ -670,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public void markCkycUploaded(final String ckycNumber, final String batchId) { this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null); - this.ckycUploadedOn = DateUtils.getLocalDateOfTenant(); + this.ckycUploadedOn = DateUtils.getBusinessLocalDate(); } /** Called after successful 3rd-party verification of all required documents. */ public void markVerified(final String ckycNumber) { this.kycStatus = ClientKycStatus.VERIFIED.getValue(); - this.verifiedOn = DateUtils.getLocalDateOfTenant(); + this.verifiedOn = DateUtils.getBusinessLocalDate(); this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory); @@ -709,21 +758,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { return this.expiryDate; } - this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears()); + this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears()); return this.expiryDate; } /** Called when verification fails. */ public void markRejected(final String remarks) { this.kycStatus = ClientKycStatus.REJECTED.getValue(); - this.rejectedOn = DateUtils.getLocalDateOfTenant(); + this.rejectedOn = DateUtils.getBusinessLocalDate(); this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks); } public void markPanVerified(final String fullName, final String firstName, final String lastName, final String aadhaarSeedingStatus, final String category, final String status) { this.panVerified = true; - this.panVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.panVerifiedOn = DateUtils.getBusinessLocalDate(); this.panFullName = StringUtils.defaultIfEmpty(fullName, null); this.panFirstName = StringUtils.defaultIfEmpty(firstName, null); this.panLastName = StringUtils.defaultIfEmpty(lastName, null); @@ -749,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public void markAadhaarVerified() { this.aadhaarVerified = true; - this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate(); } public void markGstinVerified() { this.gstinVerified = true; - this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.gstinVerifiedOn = DateUtils.getBusinessLocalDate(); } public void markCinVerified() { this.cinVerified = true; - this.cinVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.cinVerifiedOn = DateUtils.getBusinessLocalDate(); } public void markDinVerified() { this.dinVerified = true; - this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.dinVerifiedOn = DateUtils.getBusinessLocalDate(); } public void markDinVerified(final String dinNumber, final String mcaResponseJson) { this.dinNumber = dinNumber; this.dinVerified = true; - this.dinVerifiedOn = DateUtils.getLocalDateOfTenant(); + this.dinVerifiedOn = DateUtils.getBusinessLocalDate(); this.isDirector = true; this.dinMcaResponse = mcaResponseJson; } @@ -791,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { } else if (type != null && type.isNonIndividual()) { allDone = this.panVerified && this.gstinVerified; } - if (allDone && !getKycStatus().isVerified()) { + // Re-stamped even when the record already reads VERIFIED, because that is exactly what a + // periodic re-KYC is: an already-verified customer verified afresh. + // + // Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock + // that now measures from it — frozen at the original onboarding. An officer who re-verified + // both PAN and Aadhaar at the counter saw both rows stamped with today's date and the + // account still sitting on the inactive report, with nothing to say what was missing. + // + // Guarded on the verifications rather than the status, so this only fires when the checks + // that constitute a verification have actually been done again. + if (allDone) { markVerified(this.ckycNumber); } return allDone; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java index d42bb01ab..b79dbe3d5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java @@ -93,6 +93,26 @@ public class ClientRiskRule { @Column(name = "order_position", nullable = false) private Integer orderPosition; + /** + * Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}. + * + * This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it + * reads occupation / income / turnover to categorise a customer at onboarding (RBI Master + * Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction + * pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert} + * (Section 13). + * + * The distinction used to be inferred from "has no criteria set", which migration 0448 erased + * by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching + * ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a + * rule states what it is; nothing has to guess from the shape of its other columns. + */ + @Column(name = "rule_kind", length = 10) + private String ruleKind; + + public static final String KIND_KYC = "KYC"; + public static final String KIND_AML = "AML"; + protected ClientRiskRule() {} /** @@ -104,8 +124,9 @@ public class ClientRiskRule { final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome, final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus, final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag, - final String dormantFlag) { + final String dormantFlag, final String ruleKind) { final ClientRiskRule rule = new ClientRiskRule(); + rule.ruleKind = ruleKind; rule.ruleName = ruleName; rule.reason = reason; rule.riskCategory = riskCategory; @@ -137,6 +158,19 @@ public class ClientRiskRule { return id; } + public String getRuleKind() { + return ruleKind; + } + + /** + * True when this rule may take part in onboarding risk categorisation. Rows predating the + * rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded + * into the same table. + */ + public boolean isKycRule() { + return !KIND_AML.equalsIgnoreCase(this.ruleKind); + } + public String getRuleName() { return ruleName; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 32e53df8f..aaa9143aa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -32,6 +32,7 @@ import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.closure.domain.GLClosureRepository; import org.apache.fineract.accounting.common.AccountingConstants; import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; +import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; @@ -647,26 +648,39 @@ public class AccountingProcessorHelper { final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { - final GLAccount expenseAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, - CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + // The payable comes from the Interest Payable row on the product's Accounting tab, which is + // where a bank actually picks it. The reserved name in the product's other GL mappings is + // consulted only after that, for a product configured before that row was mapped, and the + // Savings Control last of all - interest owed is a liability either way. final GLAccount payableAccount = Optional - .ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)).orElse(controlAccount); + .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, + AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) + .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) + .orElse(controlAccount); final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // Stage 1: the expense is incurred and parked in the payable. - postSavingsLeg(office, currencyCode, expenseAccount, payableAccount, savingsId, transactionId, transactionDate, amount, - isReversal); + // The interest expense is not raised here. It is recognised as the interest accrues, day by + // day, and this posting settles that recognised debt - raising the expense again at payout + // would put the same interest on the P&L twice. This mirrors the pooled posting service, + // which pays a product's whole membership the same way; a closure reaching this path must + // produce the same entries as the nightly run, or the two disagree on what one payment did. + // + // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // product names no paid account both resolve to the payable and this says nothing, so it is + // skipped and the payout comes straight out of the payable. + final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); + if (!payableIsPaid) { + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } - // Stage 2: the liability moves to the account it is paid out of. - postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - - // Stage 3: into the member's own balance. - postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + // Stage 2: into the member's own balance. + final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; + postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); } @@ -1380,6 +1394,29 @@ public class AccountingProcessorHelper { return accountMapping.getGlAccount(); } + /** + * One row of the savings product's Accounting tab, or null where the bank has not filled it in. + * + *

+ * Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is + * required. Interest Payable is offered on the product screen but a product configured before it + * existed has none, and that has to fall through to the next choice rather than stopping the + * account from being closed. + */ + private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + if (savingsProductId == null) { + return null; + } + // Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a + // missing mapping that method builds its message through CashAccountsForSavings.fromInt, + // which returns null for a row the cash enum does not carry - Interest Payable among them - + // and the lookup would die on a NullPointerException instead of the exception meant for it. + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + return mapping == null ? null : mapping.getGlAccount(); + } + private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, final Long paymentTypeId) { GLAccount glAccount; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java index adb90ec41..30dd40bb5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java @@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener scheduledJobDetails = schedularWritePlatformService .retrieveAllJobs(fineractProperties.getNodeId()); for (final ScheduledJobDetail jobDetails : scheduledJobDetails) { + // Release any currently_running flag left over from before this boot. + // + // The flag is set in the scheduler's veto check before a job's body runs and cleared + // by the Quartz listener when it returns. Two things leave it set with nothing + // running: a JVM that stopped mid-run, and a job started through its own API rather + // than through Quartz — no listener is involved there, so nothing ever clears it. + // + // Once set, the veto silently drops every later trigger: no run-history row, no + // error, nothing in the log. The job reads as though it ran and did nothing, and one + // dead run mutes it for good. That is how a night of unpaid savings interest goes + // unnoticed. + // + // This loop is the platform coming up, so by definition none of these jobs is + // running: whatever the flag says, it is stale. scheduleJob() below already clears + // it on the paths it reaches, but not when it throws before that line, and the + // saveOrUpdate() after this loop persists whatever we set here either way. + jobDetails.setCurrentlyRunning(false); try { jobRegisterService.scheduleJob(jobDetails); } catch (JobIsNotFoundOrNotEnabledException e) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 290d81092..b2926c7ff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.domain.KycExpiryStage; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -91,6 +93,7 @@ public class CashierDepositRequestApiResource { private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L; private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; @@ -145,6 +148,7 @@ public class CashierDepositRequestApiResource { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw apiError(400, "Amount must be greater than zero"); } + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT); validateDenominationTotal(denominationData, amount); validateDdLimit(ddFaceValue); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index a9b60edf5..c9cce3fc3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -67,6 +67,7 @@ import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProces import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; @@ -82,6 +83,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; public class ClerkDisbursementRequestApiResource { private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; @@ -143,6 +145,14 @@ public class ClerkDisbursementRequestApiResource { if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) { throw apiError(400, "Each entry must have an account number"); } + // Checked by the direction of the leg. A Dr leg takes money out of the account and is + // stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps + // working, since nothing about a restricted customer should prevent them being paid. + // A GL leg carries no savingsAccountId and the guard ignores it. + if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), + "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); + } if ("Dr".equals(dbCr)) { totalDr = totalDr.add(entryAmt); } else { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 33bc6756b..4a7b680b4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.domain.KycExpiryStage; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.slf4j.Logger; @@ -77,6 +79,7 @@ public class ClerkWithdrawalRequestApiResource { private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class); private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; @@ -121,6 +124,7 @@ public class ClerkWithdrawalRequestApiResource { throw apiError(400, "Token number (TOK. NO.) is required"); } validateTokenUniqueToday(tokNo, null); + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -453,6 +457,11 @@ public class ClerkWithdrawalRequestApiResource { final Long savingsAccountId = record.get("savings_account_id") != null ? ((Number) record.get("savings_account_id")).longValue() : null; + // Checked again on approval, not only on submission: a request can sit in the queue for days, + // and the account can cross the dormancy threshold while it waits. The savings withdrawal + // below refuses a dormant account anyway, but that failure would surface to the manager as a + // rejected transaction after the approval was recorded; this says why, before anything moves. + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java index 7eebbe34a..4150ec7cf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java @@ -27,6 +27,10 @@ public final class KycRiskRuleData implements Serializable { private final Long id; private final String ruleName; + /** "KYC" (profile-driven, categorises at onboarding) or "AML" (transaction-monitoring + * scenario). The client risk calculator evaluates KYC rules only - an AML rule describes + * behaviour and may only fire against a posted transaction. */ + private final String ruleKind; private final Integer legalFormId; private final Long occupationCategoryCvId; private final String occupationCategoryName; @@ -61,7 +65,7 @@ public final class KycRiskRuleData implements Serializable { private final BigDecimal txnMinLimitAmount; private final BigDecimal txnMaxLimitAmount; - public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId, + public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId, final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCvId, final String occupationName, final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome, @@ -75,6 +79,7 @@ public final class KycRiskRuleData implements Serializable { final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) { this.id = id; this.ruleName = ruleName; + this.ruleKind = ruleKind; this.legalFormId = legalFormId; this.occupationCategoryCvId = occupationCategoryCvId; this.occupationCategoryName = occupationCategoryName; @@ -111,6 +116,10 @@ public final class KycRiskRuleData implements Serializable { return ruleName; } + public String getRuleKind() { + return ruleKind; + } + public Integer getLegalFormId() { return legalFormId; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java index 5df50e4da..6f4b82865 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java @@ -40,6 +40,12 @@ public class KycRiskRuleRequest { @Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED) private String ruleName; + @Schema(description = "Which family the rule belongs to: KYC for a profile rule that categorises a customer at " + + "onboarding from occupation / income / turnover, or AML for a transaction-monitoring scenario that may " + + "only fire against posted transactions. Omit to have it inferred - supplying transaction settings, or " + + "naming no profile criteria at all, makes it AML.", example = "KYC") + private String ruleKind; + @Schema(description = "Reason / criteria text explaining why the rule exists", requiredMode = Schema.RequiredMode.REQUIRED) private String reason; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index 33e3347a3..957f003d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -52,6 +52,8 @@ import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTrans import org.apache.fineract.portfolio.client.kyc.KycVerificationResult; import org.apache.fineract.portfolio.client.kyc.KycVerificationService; import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; @@ -73,6 +75,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc private final KycVerificationService kycVerificationService; private final FromJsonHelper fromApiJsonHelper; private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService; + private final SavingsAccountRepository savingsAccountRepository; private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService; /** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */ @@ -414,8 +417,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc getJsonString(panData, "aadhaar_seeding_status"), getJsonString(panData, "category"), getJsonString(panData, "status")); - kyc.tryAutoVerify(); + final boolean fullyVerified = kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); + // Both checks now done, so this is a completed re-KYC and the dormancy it clears is the + // same one the explicit command clears. + if (fullyVerified) { + reviveDormantAccounts(clientId); + } LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue()); final Map changes = new java.util.LinkedHashMap<>(); @@ -458,8 +466,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc } kyc.markAadhaarVerified(); - kyc.tryAutoVerify(); + final boolean fullyVerified = kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); + // Both checks now done, so this is a completed re-KYC and the dormancy it clears is the + // same one the explicit command clears. + if (fullyVerified) { + reviveDormantAccounts(clientId); + } final Map changes = new java.util.LinkedHashMap<>(); changes.put("aadhaarVerified", true); @@ -500,8 +513,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc } kyc.markGstinVerified(); - kyc.tryAutoVerify(); + final boolean fullyVerified = kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); + // Both checks now done, so this is a completed re-KYC and the dormancy it clears is the + // same one the explicit command clears. + if (fullyVerified) { + reviveDormantAccounts(clientId); + } final Map changes = new java.util.LinkedHashMap<>(); changes.put("gstinVerified", true); @@ -563,8 +581,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc } kyc.markCinVerified(); - kyc.tryAutoVerify(); + final boolean fullyVerified = kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); + // Both checks now done, so this is a completed re-KYC and the dormancy it clears is the + // same one the explicit command clears. + if (fullyVerified) { + reviveDormantAccounts(clientId); + } final Map changes = new java.util.LinkedHashMap<>(); changes.put("cinVerified", true); @@ -633,8 +656,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc final boolean companyNameMatched = matchedCompany != null; kyc.markDinVerified(dinNumber, rawMcaResponse); - kyc.tryAutoVerify(); + final boolean fullyVerified = kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); + // Both checks now done, so this is a completed re-KYC and the dormancy it clears is the + // same one the explicit command clears. + if (fullyVerified) { + reviveDormantAccounts(clientId); + } final Map changes = new java.util.LinkedHashMap<>(); changes.put("dinNumber", dinNumber); @@ -817,11 +845,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc kyc.markVerified(kyc.getCkycNumber()); kycRepositoryWrapper.saveAndFlush(kyc); + // A completed re-KYC is what revives the customer's dormant accounts. + // + // RBI's Master Direction allows a dormant account to be operated again only once the + // customer's identity has been established afresh, and this command is that establishment. + // Withdrawals are refused while an account is dormant and no transaction lifts it, so + // without this the account would stay frozen with no way out. + // + // Done here rather than in the nightly expiry sweep on purpose: that sweep visits every + // customer whose KYC is merely current, so reviving there would reopen a dormant account on + // the next run without anyone having verified anything. + final int revived = reviveDormantAccounts(clientId); + final Map changes = new java.util.LinkedHashMap<>(); changes.put("verifiedOn", kyc.getVerifiedOn()); changes.put("expiryDate", kyc.getExpiryDate()); changes.put("kycStatus", kyc.getKycStatusValue()); changes.put("kycStatusCode", "kyc.status.verified"); + if (revived > 0) { + changes.put("dormantAccountsRevived", revived); + LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived); + } return buildResult(command, client, clientId, kycId, changes); } @@ -1040,6 +1084,39 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc // ── helpers ─────────────────────────────────────────────────────────────── + /** + * Clears the dormancy flag from this client's savings accounts, on the strength of a completed + * re-KYC. + * + *

+ * RBI's Master Direction allows a dormant account to be operated again only once the customer's + * identity has been established afresh, so this is the one thing that lifts it — no transaction + * does, and a withdrawal is refused while the flag stands. Called from every path that + * completes a verification, not only the explicit complete-rekyc command: an officer who + * re-verifies PAN and Aadhaar at the counter has done the same verification and must not have + * to know about a second button. + * + *

+ * Deliberately not called from the nightly expiry sweep, which visits every customer whose KYC + * is merely current — reviving there would reopen a dormant account on the next run without + * anyone having verified anything. + * + * @return how many accounts were revived + */ + private int reviveDormantAccounts(final Long clientId) { + int revived = 0; + for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) { + if (account.reviveOnReKyc()) { + savingsAccountRepository.save(account); + revived++; + } + } + if (revived > 0) { + LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived); + } + return revived; + } + private CommandProcessingResult buildResult(final JsonCommand command, final Client client, final Long clientId, final Long kycId, final Map changes) { return new CommandProcessingResultBuilder() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java new file mode 100644 index 000000000..ba568c32c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java @@ -0,0 +1,201 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.portfolio.client.domain.KycExpiryStage; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * What a customer with a lapsed periodic re-KYC may still do with their savings account. + * + *

+ * One service because the answer has to be the same at every counter the money can leave by — the + * clerk's withdrawal request, the cashier's deposit, an account-to-account transfer, a transfer to + * or from a GL. Those are separate resources that each validate their own input, and a rule copied + * into four of them is a rule that will disagree with itself the first time one is edited. + * + *

The ladder

+ * + * + * + * + * + * + *
Days overdueMoney out (withdrawal, transfer out)Money in (deposit, transfer in)
0 – 89 (grace)allowedallowed
90 – 179stoppedallowed
180 and paststoppedstopped
+ * + *

+ * Nothing at all is restricted during the grace window, and that is the point of it: RBI's 2021 + * amendment is explicit that an account is not to be restricted merely because periodic KYC is + * pending, and that any restriction follows notice and a fair opportunity to comply. The customer + * is being chased, not shut out. + * + *

+ * The line is drawn by direction, not by the counter the money crosses. Anything taking money out + * is stopped once debits stop — a transfer out is a withdrawal by another name — while anything + * paying money in keeps working, whether it is cash at the counter or a transfer from another + * account. A salary, a benefit, a relative sending funds: those must land, and none of them can + * move the balance anywhere while the debit side is shut. + * + *

Why the days are counted rather than read off the account

+ * + *

+ * The nightly job stamps the restriction on the savings account. Between a customer crossing a + * threshold and that job next running the account still reads as unrestricted, and a request + * raised in that window is accepted — the clerk is told it went through and the customer waits for + * money that was never going to be paid. Counting here closes that window, and matches the job so + * the two can never disagree. + */ +@Service +@RequiredArgsConstructor +public class KycRestrictionGuard { + + private final JdbcTemplate jdbcTemplate; + + /** What the caller is trying to do, which decides how far down the ladder it survives. */ + public enum Operation { + + /** Money out over the counter. Stopped once debits stop. */ + DEBIT("Withdrawal"), + /** Money in, however it arrives. Allowed until the account is inoperative. */ + CREDIT("Deposit"), + /** Money out to another account or a GL — a withdrawal by another name, so stopped with them. */ + TRANSFER("Transfer"); + + private final String label; + + Operation(final String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + /** + * Refuses the operation where the customer's re-KYC has lapsed far enough to stop it. + * + *

+ * Silent for a null account, a client with no KYC record, and one whose KYC has no expiry date + * — none of those is a lapsed re-KYC, and refusing on absent data would block a counter over a + * record that was never set up. + * + * @throws PlatformDataIntegrityException naming the stage and what would lift it + */ + public void check(final Long savingsAccountId, final Operation operation) { + checkDormancy(savingsAccountId, operation); + checkKycExpiry(savingsAccountId, operation); + } + + private void checkKycExpiry(final Long savingsAccountId, final Operation operation) { + final Long daysOverdue = daysOverdue(savingsAccountId); + if (daysOverdue == null) { + return; + } + final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue); + if (stage == null || stage.isGrace()) { + // Inside the grace window nothing is restricted; the customer is only being chased. + return; + } + // Past grace, a credit is the one thing still let through, until the account is inoperative. + if (operation == Operation.CREDIT && !stage.isInoperative()) { + return; + } + throw new PlatformDataIntegrityException("error.msg.client.kyc.lapsed." + operation.name().toLowerCase(), + operation.getLabel() + " is not allowed. The customer's periodic re-KYC lapsed " + daysOverdue + + " days ago. Complete the re-KYC to lift this."); + } + + /** + * Refuses money leaving, or moving, on an account nobody has operated for the product's + * dormancy period. + * + *

+ * Checked alongside the re-KYC ladder because the two restrict for the same reason and are + * lifted by the same act: an account untouched for two years is the one an impersonator + * reaches for, and RBI allows it to be operated again only once the customer's identity has + * been established afresh. Deposits are left alone — money coming in cannot be a fraud against + * the customer — but a transfer moves the balance somewhere a debit block does not follow, so + * it is refused in either direction. + * + *

+ * The days are counted from the same clock the ladder measures, so an account that crossed the + * threshold before the nightly job stamped it is caught all the same. + */ + private void checkDormancy(final Long savingsAccountId, final Operation operation) { + if (savingsAccountId == null || operation == Operation.CREDIT) { + return; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT sp.days_to_dormancy, + (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') + - GREATEST(sa.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), + sa.activatedon_date)) AS days_idle + FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0 + """, savingsAccountId); + if (rows.isEmpty()) { + return; + } + final Number threshold = (Number) rows.get(0).get("days_to_dormancy"); + final Number daysIdle = (Number) rows.get(0).get("days_idle"); + if (threshold == null || daysIdle == null || daysIdle.longValue() < threshold.longValue()) { + return; + } + final long years = threshold.longValue() / 365; + final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days"; + throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(), + operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for " + + period + ". It must be reactivated after the customer's identity is re-verified (re-KYC)."); + } + + /** + * Days past the customer's re-KYC due date, or null where there is nothing overdue to measure. + * + *

+ * Measured against the business date, not the wall clock, so a branch working a backdated day + * sees the restrictions that applied on that day. + */ + public Long daysOverdue(final Long savingsAccountId) { + if (savingsAccountId == null) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue + FROM m_savings_account sa + JOIN m_client_kyc k ON k.client_id = sa.client_id + WHERE sa.id = ? AND k.expiry_date IS NOT NULL + ORDER BY k.expiry_date DESC + LIMIT 1 + """, savingsAccountId); + if (rows.isEmpty()) { + return null; + } + final Number daysOverdue = (Number) rows.get(0).get("days_overdue"); + return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java index f896accad..4e14e91fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -66,6 +66,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat return new KycRiskRuleData( rule.getId(), rule.getRuleName(), + rule.isKycRule() ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML, rule.getLegalFormId(), occCatId, occCatName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java index 9ee8786b0..67bdbf8f6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java @@ -59,6 +59,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl // inserting into the middle of the catalog would re-prioritise existing rules. final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition(); + final String ruleKind = resolveRuleKind(request); + final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(), request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(), request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(), @@ -67,7 +69,7 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"), StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"), StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"), - StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE")); + StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"), ruleKind); if (request.getOccupationCategoryCvId() != null) { final CodeValue occupationCategory = this.codeValueRepository @@ -88,6 +90,44 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl return this.readPlatformService.retrieveRule(saved.getId()); } + /** + * Decides whether a newly authored rule takes part in onboarding risk categorisation. + * + * An explicit {@code ruleKind} from the caller wins. Otherwise the kind is inferred once, at + * authoring time, from what the request actually describes: a rule carrying live-transaction + * settings is a monitoring scenario, and so is a criteria-less rule - it names no occupation, + * no legal form and no income or turnover band, so as a KYC rule it would match every customer + * who ever walks in. That is precisely how "Additional RFIs Rule - 91" came to categorise a + * salaried employee as Medium Risk and flag them for AML. + * + * Inferring here rather than during evaluation is the point: the answer is written to the row + * and stays stable, so a later migration touching the criteria columns cannot silently change + * how an existing rule is treated. + */ + private String resolveRuleKind(final KycRiskRuleRequest request) { + if (StringUtils.isNotBlank(request.getRuleKind())) { + return ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim()) ? ClientRiskRule.KIND_AML + : ClientRiskRule.KIND_KYC; + } + if (request.hasTransactionRule()) { + return ClientRiskRule.KIND_AML; + } + return hasKycCriteria(request) ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML; + } + + /** A bound of zero is "not configured", the same reading the risk calculator applies. */ + private boolean hasKycCriteria(final KycRiskRuleRequest request) { + return request.getLegalFormId() != null || request.getOccupationCategoryCvId() != null + || request.getOccupationCvId() != null || isConfigured(request.getMinMonthlyIncome()) + || isConfigured(request.getMaxMonthlyIncome()) || isConfigured(request.getMinAnnualIncome()) + || isConfigured(request.getMaxAnnualIncome()) || isConfigured(request.getMinAnnualTurnover()) + || isConfigured(request.getMaxAnnualTurnover()); + } + + private boolean isConfigured(final BigDecimal amount) { + return amount != null && amount.compareTo(BigDecimal.ZERO) > 0; + } + private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) { // The band's lower bound is what the rule fires at unless an explicit threshold is given, // so a rule described purely as a band is still evaluable against live transactions. @@ -114,6 +154,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100); validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500); validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3); + if (StringUtils.isNotBlank(request.getRuleKind()) && !ClientRiskRule.KIND_KYC.equalsIgnoreCase(request.getRuleKind().trim()) + && !ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim())) { + validator.reset().parameter("ruleKind").value(request.getRuleKind()).failWithCode("must.be.kyc.or.aml"); + } validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull() .integerGreaterThanZero(); validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index af0596d68..f3d98a4a1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -48,8 +48,19 @@ public class SavingsDailyInterestOrchestrator { private final SavingsDailyInterestService accrualService; private final SavingsPooledInterestPostingService postingService; - /** Accrue for the day, then post for any product whose cycle ends today. */ + /** + * Accrue for the day, then post for any product whose posting date has arrived. + * + * Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the + * posting then reads and updates those same rows in a transaction of its own + * ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back + * another's payment). Holding an outer transaction across both would mean this thread still owns + * the row locks the accrual took while the posting's separate transaction waits for them — each + * waiting on the other, and the job hanging silently with nothing in the log. So each step + * commits on its own before the next begins. + */ public void runForDate(final LocalDate businessDate) { + clearStaleRunningFlag(); accrualService.accrueForDate(businessDate); enrolNewProducts(); @@ -73,18 +84,27 @@ public class SavingsDailyInterestOrchestrator { for (final Map schedule : schedules) { final long productId = ((Number) schedule.get("savings_product_id")).longValue(); - final Long dueCount = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM m_savings_daily_interest - WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true - AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0 - """, Long.class, productId, businessDate); - if (dueCount == null || dueCount == 0) { - updateSchedule(productId, businessDate, null); - continue; - } + // Whether anything is due is decided inside postForProduct, not here. + // + // This used to pre-check with its own COUNT over m_savings_daily_interest, and that + // deadlocked the run: accrueForDate above is @Transactional and has just written every + // ledger row, so its transaction still holds them; the COUNT then read those rows on + // this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a + // transaction of its own — sat waiting for a lock this thread was holding and would only + // release after postForProduct returned. Each waited on the other and the job hung + // silently, with nothing in the log and the flag left saying it was still running. + // + // postForProduct already selects what is due and returns zero when there is nothing, so + // the pre-check bought nothing but the deadlock. Its selection is by posting_date rather + // than by the run date, which is what makes a missed posting date catch up on a later + // run instead of being skipped for good. try { final BigDecimal paid = postingService.postForProduct(productId, businessDate); + if (paid.signum() == 0) { + updateSchedule(productId, businessDate, null); + continue; + } updateSchedule(productId, businessDate, "COMPLETED"); log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); } catch (final RuntimeException e) { @@ -112,6 +132,34 @@ public class SavingsDailyInterestOrchestrator { } } + /** + * Releases this job's own currently_running flag if an earlier run left it set. + * + * The platform sets that flag in the scheduler's veto check before a job's body runs, and clears + * it in the Quartz listener when the body returns. Two things leave it set with nothing running: + * a JVM stopped mid-run, and a run started through this service's own API rather than through + * Quartz — the listener is never involved there, so nothing ever clears it. + * + * Once set, the veto silently drops every later trigger: no run-history row, no error, nothing in + * the log. The job reads as though it ran and did nothing, which is exactly how a night of unpaid + * interest goes unnoticed. It happened here — a manual API run left the flag behind and the job + * was mute from then on. + * + * Reaching this line is itself the proof that no other run is in flight: Quartz will not start a + * second execution of a job it is already running, and this is the first statement of the only + * method that runs one. So the flag can only be a leftover, and clearing it means one dead or + * out-of-band run cannot mute the job for good. + */ + private void clearStaleRunningFlag() { + final int cleared = jdbcTemplate.update(""" + UPDATE job SET currently_running = false + WHERE name = 'Savings Daily Interest Accrual And Posting' AND currently_running = true + """); + if (cleared > 0) { + log.warn("Savings interest: cleared a stale currently_running flag left by an earlier run"); + } + } + /** * Enrols the product behind one account, if it is not already on the timetable. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index c3b60665e..dbd2fd400 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -257,10 +257,31 @@ public class SavingsDailyInterestService { cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, financialYearBeginningMonth); } else { - carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); cycleNumber = ((Number) prev.get("cycle_number")).intValue(); cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); cycleEnd = prevCycleEnd; + + // Days between the last row and today are earned days too, and have to be added. + // + // The ledger keeps one row per account, so the "previous" row is simply the last day + // the job ran for this account — not necessarily yesterday. Where nights were missed, + // or a business date was moved forward by more than a day, every day in between is a + // day the member held a balance and earned on it. Carrying the previous row's total + // forward unchanged and adding only today's interest silently drops all of them: an + // account activated on the 8th and accrued again on the 30th was credited two days' + // interest instead of twenty-three. + // + // So the gap is replayed from the account's own transactions, the same way a first + // row is built, and the result stands in for the running total the ledger would be + // holding had it run every night. + final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate(); + if (prevBalanceDate.plusDays(1).isBefore(businessDate)) { + final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, + minBalance, averageDailyBalanceOf(calcType)); + carriedInterest = gap.cumulativeInterest(); + } else { + carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 561492159..724686933 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -28,7 +28,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** @@ -40,28 +39,41 @@ import org.springframework.transaction.annotation.Transactional; * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere * holds the figure that would say so. * - * This posts the product's total first, into the liability the product names as its Savings Interest - * Payable, moves it to the account it is paid out of, and only then pays it to members: + * This settles the debt the accrual has already recognised: the product's total moves out of the + * liability the product names as its Savings Interest Payable, into the account it is paid out of, + * and only then to the members: * *

- *   Dr  Interest On Savings        (expense, from the product)
- *   Cr  Savings Interest Payable   (liability, from the product)     what the bank now owes
- *
- *   Dr  Savings Interest Payable
+ *   Dr  Savings Interest Payable   (liability, from the product)
  *   Cr  Savings Interest Paid                                         on its way out
  *
  *   Dr  Savings Interest Paid      (per member)
  *   Cr  Savings Control            (per member, from the product)     in the member's hands
  * 
* - * Every account named above is read from the product's own GL configuration; none is written into - * this class. A product that names no paid account collapses the middle step - both legs resolve to - * the payable - and posts the two-legged entry it always did; one that names no payable falls back - * to its own Savings Control, since interest owed is a liability either way. + * The interest expense is not raised here. It is recognised as the interest accrues, day by day, and + * this is the settlement of what that recognition already booked - raising the expense again at + * payout would put the same interest on the P&L twice. * - * The whole product settles inside one transaction, and the payable has to read zero before it is - * allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run - * that never reached commit leaves a PENDING row naming the product and date for the next run. + * Every account named above is read from the product's own GL configuration; none is written into + * this class. The payable comes from the Interest Payable row on the product's Accounting tab, and + * where that is unset, from the product's reserved GL mapping of the same name, and failing both + * from its Savings Control - interest owed is a liability either way. + * + * The first step is dropped where it would say nothing, and the payout then comes straight out of + * the payable: + * + *
+ *   Dr  Savings Interest Payable   (per member)
+ *   Cr  Savings Control            (per member)
+ * 
+ * + * That happens when the product names no paid account, so both legs of the first step resolve to the + * payable and it would debit and credit one account for the same amount. + * + * The whole product settles inside one transaction. A failure rolls the product back entirely - + * nobody is half paid - and a run that never reached commit leaves a PENDING row naming the product + * and date for the next run. * * The paise problem is the one worth stating plainly. Each member's interest is rounded to two * decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool @@ -92,20 +104,32 @@ public class SavingsPooledInterestPostingService { private static final int INTEREST_POSTING = 3; /** acc_gl_journal_entry.entity_type_enum for a savings entry. */ private static final int ENTITY_TYPE_SAVINGS = 2; - private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3; private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2; + /** + * {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product's + * Accounting tab, which is where a bank picks the account interest is owed from. + */ + private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17; private final JdbcTemplate jdbcTemplate; /** * Posts one product's interest for one posting date. * - * REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other - * product's completed run down with it. - * * @return the amount distributed, or zero if there was nothing to do + * + *

+ * REQUIRED, not REQUIRES_NEW. + * + * One product's failure must not roll back another's payment, and REQUIRED gives that already: + * the orchestrator calls this outside any transaction of its own, so each product opens and + * commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is + * running and waits for locks that transaction may hold. The accrual step immediately before + * writes every account's ledger row, and this method reads and updates those same rows: with + * REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding, + * and the job hung with nothing in the log and its currently_running flag left set. */ - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { final Long completed = jdbcTemplate.queryForObject(""" SELECT COUNT(*) FROM m_savings_interest_posting_run @@ -116,8 +140,18 @@ public class SavingsPooledInterestPostingService { return BigDecimal.ZERO; } - // The accounts due today, with what they accrued. Anything already carrying a posting - // transaction is skipped, so a resumed run cannot pay somebody a second time. + // Every account whose posting date has arrived or passed and has not yet been paid. + // + // Read by posting_date rather than by the run date: the ledger keeps one row per account and + // overwrites it daily, so on the day after a quarter end the row saying "this was a posting + // day" no longer exists. Matching on the run date alone meant a quarter end the job did not + // run on was never paid at all — the cycle simply rolled over with the interest carried into + // it. A posting that is owed stays owed until it is made, which is how an overnight batch has + // to behave when a night is missed. + // + // Anything already carrying a posting transaction is skipped, so a resumed run cannot pay + // somebody a second time. + // // A savings account has no office of its own: it belongs to a client, or to a group when // there is no client, and the office is theirs. The journal entries have to be raised // against that office or they land in the wrong branch's books. @@ -130,7 +164,7 @@ public class SavingsPooledInterestPostingService { JOIN m_savings_account sa ON sa.id = d.savings_account_id LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id - WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true + WHERE d.savings_product_id = ? AND d.posting_date <= ? AND d.savings_transaction_id IS NULL AND d.cycle_cumulative_interest > 0 AND COALESCE(c.office_id, g.office_id) IS NOT NULL @@ -168,16 +202,23 @@ public class SavingsPooledInterestPostingService { } // The three accounts the bill passes through on its way to the members. Every one is read - // from the product's own configuration — the standard Interest On Savings mapping, and the - // two reserved names on the product's Accounting tab — so a bank on its own chart of - // accounts is posted to the accounts it chose. No GL code is named in this class. + // from the product's own configuration, so a bank on its own chart of accounts is posted to + // the accounts it chose. No GL code is named in this class. // - // Where a product leaves a reserved name unset the bill still has somewhere to go: the - // payable falls back to the product's Savings Control (interest owed is a liability either - // way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and - // leaves the two-legged posting a product without these mappings has always had. - final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); - final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), + // The product's Accounting tab is the authority, and it is consulted first for all three: + // Interest On Savings for the expense, and the standard Interest Payable row for the + // payable. Those are the rows a bank actually fills in on the product screen. + // + // The reserved names in the product's other GL mappings are consulted only after that, as a + // fallback for a product configured before Interest Payable was mapped, and for the paid leg, + // which has no standard row of its own. + // + // Where a product sets none of them the bill still has somewhere to go: the payable falls + // back to the product's Savings Control (interest owed is a liability either way), and the + // paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged + // posting a product without these mappings has always had. + final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE), + namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL)); final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl); @@ -185,24 +226,30 @@ public class SavingsPooledInterestPostingService { final String currency = (String) due.get(0).get("currency_code"); final String poolReference = "SIP-" + productId + "-" + postingDate; - // Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's - // interest is parked in the payable, where it stands as a debt to the members until it - // reaches them. - journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null); - - // Stage 2: the debt moves to the account the payout is made from, so what is still merely - // owed and what is on its way out are two separate balances rather than one. + // Stage 1: the debt the accrual has been building moves out of the payable and into the + // account the payout is made from, so what is still merely owed and what is on its way to + // the members are two separate balances rather than one. + // + // The interest expense itself is not raised here. It is recognised as the interest accrues, + // day by day, and posting is the settlement of that recognised debt — raising the expense + // again at payout would count the same interest twice on the P&L. // // Skipped where the product names no separate paid account: both legs then resolve to the - // payable and this would debit and credit the same account for the same amount — a pair of - // entries that say nothing and clutter the ledger. - if (!payableGl.equals(paidGl)) { + // payable and this would debit and credit one account for the same amount, saying nothing. + final boolean payableIsPaid = payableGl.equals(paidGl); + final boolean stage1Applies = !payableIsPaid; + if (stage1Applies) { journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null); journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null); } - // Stage 3: out to the members, one at a time. + // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2 + // draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw + // from there instead — debiting the paid account would take the money out of an account + // nothing put it into, leaving the payable standing as a debt that was in fact settled. + final Long payoutFromGl = stage1Applies ? paidGl : payableGl; + + // Stage 2: out to the members, one at a time. BigDecimal distributed = BigDecimal.ZERO; for (final Map row : due) { final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); @@ -217,7 +264,7 @@ public class SavingsPooledInterestPostingService { final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, newBalance); - journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference, + journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference, accountId); creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, accountId, transactionId); @@ -232,12 +279,22 @@ public class SavingsPooledInterestPostingService { WHERE id = ? """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); + // Both interest columns move together, and that is not a duplicate. + // + // total_interest_earned_derived is the interest the account has earned to date; + // total_interest_posted_derived is how much of that has been paid into the balance. + // Screens and validate()'s own check read the unposted interest as earned - posted, so + // raising only `posted` drives that difference negative: an account paid 26.52 with + // `earned` still at zero reports its unposted interest as -26.52, and the account screen + // showed exactly that. The accrual writes pending interest to the ledger and never to + // `earned`, so this posting is the only place `earned` is realised. jdbcTemplate.update(""" UPDATE m_savings_account SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, - total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ? + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, + total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ? WHERE id = ? - """, amount, amount, accountId); + """, amount, amount, amount, accountId); distributed = distributed.add(amount); } @@ -297,7 +354,7 @@ public class SavingsPooledInterestPostingService { private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, final LocalDate date, final BigDecimal amount, final String reference, final long accountId, final long transactionId) { - final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL + final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL); journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); } @@ -306,6 +363,18 @@ public class SavingsPooledInterestPostingService { journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); } + /** + * One journal entry leg. + * + *

+ * Fineract convention: {@code type_enum} 2 = DEBIT, 1 = CREDIT — {@code JournalEntryType} + * declares {@code CREDIT(1)} first. This read {@code debit ? 1 : 2} and so signed every leg the + * wrong way round: the member's savings-control credit was stored as a debit and the expense + * debit as a credit. Nothing downstream caught it — the run's own pooled-equals-distributed check + * compares Java values and never looks at {@code type_enum}, and migration 0531 disables the + * stock posting job, so this is the only path writing savings interest to the ledger and there + * was no correct one alongside to disagree with it. + */ private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, final Long transactionId) { @@ -318,13 +387,6 @@ public class SavingsPooledInterestPostingService { created_on_utc, last_modified_on_utc, submitted_on_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) - // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares - // CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way - // round: the member's savings-control credit was stored as a debit and the expense - // debit as a credit. Nothing downstream caught it — the run's own pooled-equals- - // distributed check compares Java values and never looks at type_enum, and migration - // 0531 disables the stock posting job, so this is the only path writing savings - // interest to the ledger and there was no correct one alongside to disagree with it. """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, debit ? "Savings interest posting" : "Savings interest posted", savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, @@ -351,16 +413,29 @@ public class SavingsPooledInterestPostingService { * products — the message has to say which product to go and configure. */ private Long mappedGlAccount(final long productId, final int financialAccountType) { + final Long found = optionalMappedGlAccount(productId, financialAccountType); + if (found == null) { + throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type " + + financialAccountType + ". Set the product's Accounting tab before interest can be posted for it."); + } + return found; + } + + /** + * One row of the product's Accounting tab, or null where the bank has not filled it in. + * + *

+ * Separate from {@link #mappedGlAccount} because not every row is required: Interest Payable is + * offered on the product screen but a product configured before it existed has none, and that + * must fall through to the next choice rather than stopping the product from being paid. + */ + private Long optionalMappedGlAccount(final long productId, final int financialAccountType) { final List found = jdbcTemplate.queryForList(""" SELECT gl_account_id FROM acc_product_mapping WHERE product_id = ? AND product_type = 2 AND financial_account_type = ? LIMIT 1 """, Long.class, productId, financialAccountType); - if (found.isEmpty()) { - throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type " - + financialAccountType + ". Set the product's Accounting tab before interest can be posted for it."); - } - return found.get(0); + return found.isEmpty() ? null : found.get(0); } /** @@ -403,7 +478,13 @@ public class SavingsPooledInterestPostingService { return found.isEmpty() ? null : found.get(0); } - private static Long firstNonNull(final Long preferred, final Long fallback) { - return preferred != null ? preferred : fallback; + /** The first configured account of those given, in order of preference. */ + private static Long firstNonNull(final Long... candidates) { + for (final Long candidate : candidates) { + if (candidate != null) { + return candidate; + } + } + return null; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java index 5b8a3977e..36cc6cf73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -31,6 +31,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; @@ -290,16 +291,33 @@ public class SavingsDailyInterestApiResource { return gson.toJson(result); } - /** Runs a day by hand, for a first backfill or after fixing a failed night. */ + /** + * Runs a day by hand, for a first backfill or after fixing a failed night. + * + * Defaults to the business date, and refuses to run past it. Accruing or posting for a date the + * bank has not reached writes interest into the books for days that have not happened: the + * member's balance goes up today for a quarter that ends next month, and the journal entries + * carry that future date. Worse, the business date can be moved back afterwards — for a test, or + * because it was advanced by mistake — and then the ledger holds a posting dated beyond the day + * the bank is working, which nothing reconciles against. + * + * To post for a later date, move the business date there first. That is the same order the + * nightly run follows, and it keeps the books and the business date in step. + */ @POST @Path("/run") public String run(@QueryParam("date") final String dateParam) { - final LocalDate businessDate = dateParam == null || dateParam.isBlank() - ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) - : LocalDate.parse(dateParam); - orchestrator.runForDate(businessDate); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final LocalDate runFor = dateParam == null || dateParam.isBlank() ? businessDate : LocalDate.parse(dateParam); + if (runFor.isAfter(businessDate)) { + throw new PlatformDataIntegrityException("error.msg.savings.interest.run.after.business.date", + "Cannot run savings interest for " + runFor + ": the business date is " + businessDate + + ". Move the business date forward first — interest cannot be accrued or posted for a day" + + " the bank has not reached."); + } + orchestrator.runForDate(runFor); final Map result = new LinkedHashMap<>(); - result.put("ranFor", businessDate.toString()); + result.put("ranFor", runFor.toString()); return gson.toJson(result); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 62bacbc6c..90f2457f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.springframework.beans.factory.annotation.Autowired; @@ -61,6 +62,7 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi private final ConfigurationDomainService configurationDomainService; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final KycRestrictionGuard kycRestrictionGuard; private final SavingsDailyInterestService savingsDailyInterestService; private final VoucherService voucherService; @@ -72,7 +74,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context, final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, final BusinessEventNotifierService businessEventNotifierService, - final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) { + final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService, + final KycRestrictionGuard kycRestrictionGuard) { this.savingsAccountRepository = savingsAccountRepository; this.savingsAccountTransactionRepository = savingsAccountTransactionRepository; this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper; @@ -83,6 +86,7 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi this.businessEventNotifierService = businessEventNotifierService; this.savingsDailyInterestService = savingsDailyInterestService; this.voucherService = voucherService; + this.kycRestrictionGuard = kycRestrictionGuard; } @Transactional @@ -93,6 +97,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi context.authenticatedUser(); account.validateForAccountBlock(); account.validateForDebitBlock(); + // Every withdrawal in the system arrives here — the clerk's counter, the core API, an + // account transfer, a standing instruction — so the dormancy and re-KYC ladders are + // enforced at this one point rather than at each of those doors. + kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.DEBIT); final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService .isSavingsInterestPostingAtCurrentPeriodEnd(); final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate(); @@ -206,6 +214,12 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi context.authenticatedUser(); account.validateForAccountBlock(); account.validateForCreditBlock(); + // Money coming in is a credit however it arrives — over the counter, or transferred from + // another account. The restriction is on the customer taking money out or moving it away, + // not on anyone paying them: a salary credit, a benefit transfer or a relative sending + // funds must land while the customer is being chased for their re-KYC, and it cannot be + // used to spirit the balance anywhere, since the debit side stays shut. + kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.CREDIT); // Global configurations final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java new file mode 100644 index 000000000..e576911c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +/** + * The one definition of when a savings account was last known to be in live use, for the dormancy + * ladder to measure from. + * + *

+ * It is written here once because it is read from six places — the nightly job's three selection + * queries, the account read used by the counter screen, the account list, and the dormancy report + * — and those places disagreeing is what makes an account inactive on one screen and active on the + * next. A change to the rule belongs in this file and nowhere else. + * + *

What counts

+ * + *

+ * A completed re-KYC, and nothing else. RBI's Master Direction on KYC makes periodic re-verification + * the thing that keeps an account operative, and a lapsed account operable again only once the + * customer's identity has been established afresh; a cash deposit at the counter is not that + * verification, so it no longer revives an account here. + * + *

+ * The account's activation stands in for a client who has never been re-verified, so a newly opened + * account does not start life a year behind. + */ +public final class DormancyClock { + + private DormancyClock() {} + + /** + * SQL for the date the dormancy ladder measures from, as an expression over the aliases + * {@code sa} (m_savings_account) and {@code c} (m_client). + * + *

+ * Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a + * WHERE clause without having to add a join it may already have — and so a client with no KYC + * record at all falls back to the activation date rather than dropping out of the result. + */ + public static final String LAST_LIVE_ON = """ + GREATEST( + sa.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), + sa.activatedon_date)) + """; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java index 6d5322c63..895dd1cea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead public List retrieveDormantAccounts(final Integer subStatus, final String officeName) { this.context.authenticatedUser(); - final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) " - + "from m_savings_account_transaction sat where sat.savings_account_id = sa.id " - + "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))"; + final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON; final StringBuilder sql = new StringBuilder(); sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, "); @@ -85,19 +83,57 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead // Left join: an escheated account has a remittance row only once the money has gone to RBI, // and the absence of one is exactly what the report has to show. sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id "); - // Only the three states the ladder sets. A blocked or a plainly active account is not - // dormant and does not belong on these reports. - sql.append("where sa.sub_status_enum in (?, ?, ?) "); + // An account belongs on these reports once the ladder has stamped it, or once the days + // have earned it — whichever comes first. + // + // The stamp is applied by the nightly job, so an account that crossed a threshold today + // still carries sub_status 0 until that job next runs, and the report showed nothing at + // all: an account 23 days past its dormancy date was absent from a report whose whole + // purpose is to list it. The elapsed count is already computed above for the daysIdle + // column and uses the job's own definition of a customer transaction, so reading the + // thresholds off the same product row here cannot disagree with what the job will do. + // + // Escheat is matched on the stamp alone. Unlike the other two it is not a label but a + // movement of money — the job closes the account and takes the balance out — and listing + // an account as escheated before that happened would report money as remitted that is + // still sitting in the account. + sql.append("where (sa.sub_status_enum in (?, ?, ?) "); + sql.append(" or (sp.is_dormancy_tracking_active = true and sa.status_enum = 300 "); + sql.append(" and (sp.days_to_inactive > 0 and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn)) + .append(" >= sp.days_to_inactive))) "); final List params = new ArrayList<>(); params.add(DateUtils.getBusinessLocalDate()); params.add(SUB_STATUS_INACTIVE); params.add(SUB_STATUS_DORMANT); params.add(SUB_STATUS_ESCHEAT); + params.add(DateUtils.getBusinessLocalDate()); if (subStatus != null) { - sql.append("and sa.sub_status_enum = ? "); - params.add(subStatus); + // Match the stamp or the stage the elapsed days have earned, so a stage filter picks up + // the same accounts the unfiltered report shows. + if (subStatus == SUB_STATUS_ESCHEAT) { + sql.append("and sa.sub_status_enum = ? "); + params.add(subStatus); + } else if (subStatus == SUB_STATUS_DORMANT) { + // Past the escheat threshold but not yet escheated still counts as dormant: the + // account is dormant until the job closes it and moves the money, and it has to + // appear somewhere until then. + sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_dormancy > 0 and ") + .append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" >= sp.days_to_dormancy)) "); + params.add(subStatus); + params.add(SUB_STATUS_ESCHEAT); + params.add(DateUtils.getBusinessLocalDate()); + } else { + sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_inactive > 0 and ") + .append(sqlGenerator.dateDiff("?", lastCustomerTxn)) + .append(" >= sp.days_to_inactive and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn)) + .append(" < sp.days_to_dormancy)) "); + params.add(subStatus); + params.add(SUB_STATUS_DORMANT); + params.add(DateUtils.getBusinessLocalDate()); + params.add(DateUtils.getBusinessLocalDate()); + } } if (officeName != null && !officeName.isBlank()) { sql.append("and o.name = ? "); @@ -114,9 +150,19 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead @Override public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { - final int subStatus = rs.getInt("subStatus"); final BigDecimal balance = rs.getBigDecimal("balance"); final long daysIdle = rs.getLong("daysIdle"); + // The stage the elapsed days put the account at, in place of the stamp — the same rule + // the WHERE clause selects on, so a row cannot appear on the report under one stage and + // be labelled another. + // + // The days replace the stamp rather than merely raising it, because the stamp can also + // be ahead: a customer transaction resets the clock, and an escheat that has not + // happened yet leaves an account reported as dormant, which is what it still is. + // Escheat itself is the exception and keeps its stamp, since the job has by then closed + // the account and moved the balance out. + final int stamped = rs.getInt("subStatus"); + final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle); return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo")) .clientId(rs.getLong("clientId")).clientName(rs.getString("clientName")) @@ -134,6 +180,27 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead .deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build(); } + /** + * The stage the elapsed days have earned, whatever the job has stamped so far. + * + *

+ * Escheat is never earned this way. Unlike the other two it is not a label but a movement + * of money — the job closes the account and takes the balance out — so an account only + * counts as escheated once that has actually happened, and until then it is reported as + * dormant, which is what it still is. + */ + private int earnedStage(final ResultSet rs, final long daysIdle) throws SQLException { + final Long toDormancy = nullableLong(rs, "daysToDormancy"); + if (toDormancy != null && toDormancy > 0 && daysIdle >= toDormancy) { + return SUB_STATUS_DORMANT; + } + final Long toInactive = nullableLong(rs, "daysToInactive"); + if (toInactive != null && toInactive > 0 && daysIdle >= toInactive) { + return SUB_STATUS_INACTIVE; + } + return 0; + } + private Long nullableLong(final ResultSet rs, final String column) throws SQLException { final long value = rs.getLong(column); return rs.wasNull() ? null : value; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 3fa41d175..008486db0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -22,9 +22,11 @@ import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsCons import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; import java.math.BigDecimal; +import lombok.extern.slf4j.Slf4j; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -66,6 +68,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; import org.apache.fineract.portfolio.savings.data.SavingsProductData; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; @@ -83,6 +86,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.RowMapper; +@Slf4j public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { private final PlatformSecurityContext context; @@ -104,14 +108,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead private final SavingsAccountAssembler savingAccountAssembler; private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; + private final SavingsDailyInterestService savingsDailyInterestService; public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { + DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, + final SavingsDailyInterestService savingsDailyInterestService) { this.context = context; this.jdbcTemplate = jdbcTemplate; this.sqlGenerator = sqlGenerator; this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; + this.savingsDailyInterestService = savingsDailyInterestService; this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); this.transactionsMapper = new SavingsAccountTransactionsMapper(); this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); @@ -122,6 +129,98 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead this.savingAccountAssembler = savingAccountAssembler; } + /** + * Re-accrues one account's interest ledger if it is not already at the business date. + * + *

+ * Guarded by a cheap read so the common case costs one indexed lookup and no write: through a + * normal day the nightly run has already brought the row to today and there is nothing to do. The + * refresh only fires when the row is genuinely behind — after the business date moves, or on an + * account whose row the nightly run has not reached yet. + * + *

+ * A row dated after the business date is refreshed too, not skipped. That is the case + * where the business date has been moved back, and the ledger has to come down to the earlier + * date just as it goes up to a later one. + * + *

+ * Only savings accounts carry a row here; fixed and recurring deposits accrue through core + * Fineract and are left alone by the refresh itself. + */ + /** + * The dormancy sub-status the elapsed days have earned, whatever the column currently says. + * + *

+ * The nightly job is what stamps the column, so between an account crossing a threshold and + * that job next running the column understates where the account actually stands — by a day in + * the ordinary case, by longer after an outage or when the business date moves by more than a + * day. Screens that read the column alone then tell the counter an account is fine when the + * transaction is about to be refused. + * + *

+ * Mirrors the job's own thresholds, which are the product's, and is only ever used to raise the + * reported status, never to lower one the job has already set. + */ + /** + * The dormancy sub-status to report: the one the elapsed days say, in place of the one the + * nightly job last stamped. + * + *

+ * Both directions, not only upward. Upward closes the gap between an account crossing a + * threshold and the job next running. Downward matters because the stamp can outlive the + * reason for it: a customer transaction resets the clock, and moving the business date back — + * which is how this is tested — leaves a DORMANT stamp on an account only 379 days idle, which + * is inactive. Either way the days are the truth and the stamp is a cache of it. + * + *

+ * Only the three dormancy states are touched. A blocked account, or one the job has escheated, + * keeps what it has: those are not positions on this ladder, and escheat in particular has + * moved money and closed the account, which no recomputation here can undo. + */ + private static int reportedDormancy(final Integer stamped, final LocalDate lastActive, final LocalDate businessDate, + final Integer daysToInactive, final Integer daysToDormancy) { + final int current = stamped == null ? SavingsAccountSubStatusEnum.NONE.getValue() : stamped; + if (current >= SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + return current; + } + return earnedSubStatus(lastActive, businessDate, daysToInactive, daysToDormancy); + } + + private static int earnedSubStatus(final LocalDate lastActive, final LocalDate businessDate, final Integer daysToInactive, + final Integer daysToDormancy) { + if (lastActive == null) { + return SavingsAccountSubStatusEnum.NONE.getValue(); + } + final long idle = ChronoUnit.DAYS.between(lastActive, businessDate); + // Escheat is deliberately not anticipated. Unlike the other two it is not a label but a + // movement of money — the job closes the account and transfers the balance to the escheat + // liability — and reporting it before that has happened would show a closed account still + // holding its balance. It stays whatever the job has actually done. + if (daysToDormancy != null && daysToDormancy > 0 && idle >= daysToDormancy) { + return SavingsAccountSubStatusEnum.DORMANT.getValue(); + } + if (daysToInactive != null && daysToInactive > 0 && idle >= daysToInactive) { + return SavingsAccountSubStatusEnum.INACTIVE.getValue(); + } + return SavingsAccountSubStatusEnum.NONE.getValue(); + } + + private void refreshInterestLedger(final Long accountId) { + try { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final List ledgerDate = this.jdbcTemplate.queryForList( + "select balance_date from m_savings_daily_interest where savings_account_id = ?", LocalDate.class, accountId); + if (!ledgerDate.isEmpty() && DateUtils.isEqual(ledgerDate.get(0), businessDate)) { + return; + } + this.savingsDailyInterestService.refreshAfterBalanceChange(accountId, businessDate); + } catch (final RuntimeException e) { + // Never break the read. The account must still open if its ledger cannot be refreshed; + // the nightly run will pick it up. + log.warn("Could not refresh the interest ledger for savings account {} before reading it", accountId, e); + } + } + @Override public Collection retrieveAllForLookup(final Long clientId) { @@ -269,6 +368,21 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead @Override public SavingsAccountData retrieveOne(final Long accountId) { + // Bring the interest ledger up to the business date before reading it. + // + // The accrual normally runs once a night, so between the business date moving and the next + // run the ledger is behind and this screen reports interest as of the older date. Moving the + // business date to 30 September and opening the account showed the interest accrued to the + // 9th, and moving it back showed the higher figure — the interest did not follow the date in + // either direction, which is what made it look broken. + // + // refreshAfterBalanceChange is built for exactly this: it rewrites the account's row from the + // day before it rather than from itself, so calling it repeatedly converges instead of + // compounding, it creates the row if the account has none, and it never propagates a failure + // to its caller. That last property is what makes it safe on a read path — a ledger that + // cannot be refreshed must not stop the account from being displayed. + refreshInterestLedger(accountId); + try { final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; @@ -379,7 +493,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + // Interest earned counts what the daily ledger has accrued but not yet paid. + // + // total_interest_earned_derived only moves when interest is posted, so between two + // posting dates it says the account has earned nothing since the last payout. The screen + // derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00 + // (or a stale figure) on accounts the accrual had been building up for weeks — one + // account had accrued 1,024.11 while the screen read 16.29. + // + // m_savings_daily_interest holds one row per account carrying the interest accrued in + // the current cycle, which is exactly the unposted amount. Adding it to what has been + // posted gives the true earned-to-date. The correlated subquery leaves accounts with no + // ledger row — fixed and recurring deposits, which core Fineract accrues itself — + // reading their own column exactly as before. + // posted_amount is subtracted because the ledger keeps the cycle's cumulative interest + // after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and + // posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived. + // Adding the cumulative figure whole would count that payment twice and report the + // account as having earned 24.90. + sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest " + + "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), " + + "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) " + + "as totalInterestEarned, "); sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); sqlBuilder.append("sa.account_balance_derived as accountBalance, "); @@ -397,11 +532,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); sqlBuilder.append("tg.id as taxGroupId, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, "); sqlBuilder.append("sp.id as productId, "); sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); @@ -521,8 +652,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); @@ -534,6 +664,20 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead LocalDate currentDate = DateUtils.getBusinessLocalDate(); if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + // Report the sub-status the elapsed days have earned, not only the one the + // nightly job has got round to stamping. + // + // The job stamps DORMANT at midnight, so an account that crossed the + // threshold during the day still reads as active until the next run — and + // that is what the counter screen showed the clerk: "Status: Active", no + // dormancy banner, on an account 23 days past its dormancy date. The clerk + // filled in the whole slip before anything refused it. + // + // The thresholds and the last-active date are already read above for the + // countdown, so this only reuses them. It is a read: the account row is not + // written here, and the job remains what actually stamps the column. + subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive, + numDaysToDormancy); if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); @@ -547,6 +691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead lastActiveTransactionDate.plusDays(numDaysToInactive)); } } + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); @@ -596,7 +741,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead BigDecimal interestNotPosted = BigDecimal.ZERO; LocalDate lastInterestCalculationDate = null; if (totalInterestEarned != null) { + // Floored at zero: this is unposted interest, and it cannot be negative. + // + // Earned and posted are two separate columns that a failed or reverted + // posting can leave inconsistent, and the difference going negative used to + // surface on the account screen as "Overdraft Interest Not Posted -25.98" — + // the screen shows this same field under that label whenever it is negative, + // on accounts with no overdraft at all. interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) { + interestNotPosted = BigDecimal.ZERO; + } lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); } @@ -830,7 +985,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + // Interest earned counts what the daily ledger has accrued but not yet paid. + // + // total_interest_earned_derived only moves when interest is posted, so between two + // posting dates it says the account has earned nothing since the last payout. The screen + // derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00 + // (or a stale figure) on accounts the accrual had been building up for weeks — one + // account had accrued 1,024.11 while the screen read 16.29. + // + // m_savings_daily_interest holds one row per account carrying the interest accrued in + // the current cycle, which is exactly the unposted amount. Adding it to what has been + // posted gives the true earned-to-date. The correlated subquery leaves accounts with no + // ledger row — fixed and recurring deposits, which core Fineract accrues itself — + // reading their own column exactly as before. + // posted_amount is subtracted because the ledger keeps the cycle's cumulative interest + // after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and + // posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived. + // Adding the cumulative figure whole would count that payment twice and report the + // account as having earned 24.90. + sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest " + + "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), " + + "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) " + + "as totalInterestEarned, "); sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); sqlBuilder.append("sa.account_balance_derived as accountBalance, "); @@ -849,11 +1025,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, "); sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); @@ -948,8 +1120,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); final String reasonForBlock = rs.getString("reasonForBlock"); final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); @@ -963,6 +1134,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead LocalDate currentDate = DateUtils.getBusinessLocalDate(); if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + // The elapsed days decide, not only the column the nightly job stamps — see + // earnedSubStatus. This is the mapper behind the single-account read, which is what + // the counter screen loads when a clerk searches an account, so without this it + // showed "Active" on an account already past its dormancy date and the clerk filled + // in the whole slip before anything refused it. + subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive, + numDaysToDormancy); if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); } @@ -973,6 +1151,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); } } + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); final String submittedByUsername = rs.getString("submittedByUsername"); @@ -1092,7 +1271,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead BigDecimal interestNotPosted = BigDecimal.ZERO; LocalDate lastInterestCalculationDate = null; if (totalInterestEarned != null) { + // Floored at zero, for the same reason as the summary mapper above: unposted + // interest cannot be negative, and a negative value reaches the account screen + // labelled "Overdraft Interest Not Posted" even on accounts with no overdraft. interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) { + interestNotPosted = BigDecimal.ZERO; + } lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); } @@ -1484,10 +1669,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); sql.append(" where sa.status_enum = 300 "); sql.append(" and sa.sub_status_enum = 0 "); - String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " - + "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false" - + " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)"; - sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive "); + sql.append(" and ").append(sqlGenerator.dateDiff("?", DormancyClock.LAST_LIVE_ON)).append(" >= sp.days_to_inactive "); try { ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); @@ -1509,7 +1691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sql.append(" where sa.status_enum = 300 "); sql.append(" and sa.sub_status_enum = 100 "); sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + DormancyClock.LAST_LIVE_ON) + " "); sql.append(" >= sp.days_to_dormancy "); @@ -1533,7 +1715,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sql.append(" where sa.status_enum = 300 "); sql.append(" and sa.sub_status_enum = 200 "); sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + DormancyClock.LAST_LIVE_ON) + " "); sql.append(" >= sp.days_to_escheat "); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 2cfc99881..1eef688a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -183,6 +183,7 @@ import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Scope; import org.springframework.jdbc.core.JdbcTemplate; @@ -426,9 +427,12 @@ public class SavingsConfiguration { @ConditionalOnMissingBean(SavingsAccountReadPlatformService.class) public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate, SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator, - SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator) { + SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator, + // @Lazy because the accrual service depends on beans from this same package: resolving + // both eagerly closes a cycle and the context fails to start. + @Lazy SavingsDailyInterestService savingsDailyInterestService) { return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator, - sqlGenerator, savingsAccountRepositoryWrapper); + sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService); } @Bean diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 108e7421a..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d0c837041..e75b002f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -611,4 +611,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml index 44a3207b6..13012f10f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml @@ -52,4 +52,31 @@ + + + + + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'job' AND column_name = 'id' AND is_identity = 'YES' + + + + SELECT setval(pg_get_serial_sequence('job', 'id'), (SELECT MAX(id) FROM job)) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml index ab7290a59..5fbd92956 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml @@ -236,4 +236,61 @@ referencedTableName="m_savings_interest_posting_run" referencedColumnNames="id"/> + + + + + + + + + + + DELETE FROM m_savings_daily_interest d + WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x + WHERE x.savings_account_id = d.savings_account_id) + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml index d73074fd4..684d73bc8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml @@ -59,6 +59,19 @@ unposted accruals are picked up on the first run rather than the second. --> + + + + INSERT INTO m_savings_interest_posting_schedule (savings_product_id, posting_period_enum, run_at_time, is_active) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml index b09709538..9272e724f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml @@ -38,6 +38,19 @@ start, so the day after a posting it begins again at that day's interest. --> + + + + SELECT d.savings_account_id AS savings_account_id, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml index a093b16d1..a28aee782 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -60,6 +60,16 @@ created. The latest day is kept: it is the one carrying the current cycle's running total. --> + + + + DELETE FROM m_savings_daily_interest d WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x @@ -68,6 +78,9 @@ + + + @@ -84,6 +97,9 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml index b28b4c8b4..03884e16d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml @@ -110,4 +110,58 @@ + + + + SELECT COUNT(*) FROM m_business_date WHERE type = 'BUSINESS_DATE' + + + INSERT INTO m_business_date + (type, date, created_by, created_date, version, last_modified_by, + lastmodified_date, created_on_utc, last_modified_on_utc) + VALUES ('BUSINESS_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW()); + + + + + + SELECT COUNT(*) FROM m_business_date WHERE type = 'COB_DATE' + + + INSERT INTO m_business_date + (type, date, created_by, created_date, version, last_modified_by, + lastmodified_date, created_on_utc, last_modified_on_utc) + VALUES ('COB_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW()); + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'enable-business-date' AND enabled = false + + + + + name = 'enable-business-date' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml new file mode 100644 index 000000000..399f961ea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + -- AML transaction-monitoring rules: anything carrying a live-transaction counterpart + -- in m_aml_transaction_rule is behaviour-driven by definition. + UPDATE m_client_risk_rule r + SET rule_kind = 'AML' + WHERE EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id); + + -- The rest of 0430's catalog: seeded with a non-CLEAR disposition, and constraining no + -- customer attribute. Both halves must hold - a bank's own high-risk KYC rule could + -- legitimately be authored as FLAGGED, but it would name an occupation or an income + -- band, so the second test keeps it in the KYC family. + UPDATE m_client_risk_rule + SET rule_kind = 'AML' + WHERE rule_kind IS NULL + AND aml_check_status IN ('FLAGGED', 'BLOCKED') + AND legal_form_id IS NULL + AND occupation_category_cv_id IS NULL + AND occupation_cv_id IS NULL + AND COALESCE(min_monthly_income, 0) = 0 + AND COALESCE(max_monthly_income, 0) = 0 + AND COALESCE(min_annual_income, 0) = 0 + AND COALESCE(max_annual_income, 0) = 0 + AND COALESCE(min_annual_turnover, 0) = 0 + AND COALESCE(max_annual_turnover, 0) = 0; + + -- The AML rows that 0448 additionally stamped with an income band, which is what put + -- them beyond the criteria test above. + -- + -- 0448 applied a 50k-200k monthly / 6L-24L annual band to "order_position BETWEEN 90 + -- AND 240", intending 0428's occupation rules. order_position is not unique across the + -- two seeds, so it landed on roughly 140 of 0430's scenarios as well - "Additional RFIs + -- Rule - 91" (the row that categorised a salaried employee as Medium Risk and flagged + -- them) along with the whole "Account Monitoring - ...", "Afghan Drug Business - ...", + -- "Credit Card Monitoring - ..." and "Customer Due Diligence - ..." families. + -- + -- Those rows are identifiable precisely: they carry that exact band, still constrain no + -- occupation or legal form, and hold a disposition only 0430 writes. A genuine KYC rule + -- from 0428 in the same order_position range always names an occupation or occupation + -- category (NRI, Business Owner, Chartered Accountant, ...), so it cannot match here; + -- 0428's two bare income rules sit at 250 and 260, outside the band 0448 applied. + UPDATE m_client_risk_rule + SET rule_kind = 'AML' + WHERE rule_kind IS NULL + AND aml_check_status IN ('FLAGGED', 'BLOCKED') + AND legal_form_id IS NULL + AND occupation_category_cv_id IS NULL + AND occupation_cv_id IS NULL + AND min_monthly_income = 50000 + AND max_monthly_income = 200000 + AND min_annual_income = 600000 + AND max_annual_income = 2400000 + AND COALESCE(min_annual_turnover, 0) = 0 + AND COALESCE(max_annual_turnover, 0) = 0; + + -- Likewise the 0430 rows caught by 0448's other two bands, for completeness: the + -- high-risk-occupation band at order_position 20/30/40/50 and the low-risk band at + -- 280-450. Same reasoning - a real 0428 rule in those ranges names an occupation. + UPDATE m_client_risk_rule + SET rule_kind = 'AML' + WHERE rule_kind IS NULL + AND aml_check_status IN ('FLAGGED', 'BLOCKED') + AND legal_form_id IS NULL + AND occupation_category_cv_id IS NULL + AND occupation_cv_id IS NULL + AND ( + (min_monthly_income = 200000 AND min_annual_income = 2400000) + OR (min_monthly_income = 0 AND max_monthly_income = 50000 + AND min_annual_income = 0 AND max_annual_income = 600000) + ); + + -- Everything left is a KYC profile rule (0428, plus anything a bank has added since). + UPDATE m_client_risk_rule SET rule_kind = 'KYC' WHERE rule_kind IS NULL; + + + + + + + UPDATE m_client_risk_rule + SET min_monthly_income = null, + max_monthly_income = null, + min_annual_income = null, + max_annual_income = null, + min_annual_turnover = null, + max_annual_turnover = null + WHERE rule_kind = 'AML'; + + + + + + + UPDATE m_client_risk_rule SET min_monthly_income = null WHERE rule_kind = 'KYC' AND min_monthly_income = 0; + UPDATE m_client_risk_rule SET max_monthly_income = null WHERE rule_kind = 'KYC' AND max_monthly_income = 0; + UPDATE m_client_risk_rule SET min_annual_income = null WHERE rule_kind = 'KYC' AND min_annual_income = 0; + UPDATE m_client_risk_rule SET max_annual_income = null WHERE rule_kind = 'KYC' AND max_annual_income = 0; + UPDATE m_client_risk_rule SET min_annual_turnover = null WHERE rule_kind = 'KYC' AND min_annual_turnover = 0; + UPDATE m_client_risk_rule SET max_annual_turnover = null WHERE rule_kind = 'KYC' AND max_annual_turnover = 0; + + + + + + + UPDATE m_client_risk_rule + SET aml_check_status = 'CLEAR' + WHERE rule_kind = 'KYC' AND aml_check_status = 'FLAGGED'; + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 1a0057291..c441155be 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -61,6 +61,7 @@ import java.math.MathContext; import java.time.LocalDate; import java.time.YearMonth; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -1434,10 +1435,17 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom addTransaction(transaction); } - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } + // Nothing on the dormancy ladder is cleared by a transaction — not dormancy, and not + // inactivity either. + // + // RBI's Master Direction makes periodic re-verification the thing that keeps an account + // operative, and a lapsed one operable again only once the customer's identity has been + // established afresh. A cash-in at the counter is not that verification, so it cannot + // stand in for it: the sub-status is cleared by a completed re-KYC and by nothing else + // (see reviveOnReKyc), and DormancyClock measures the ladder from that same re-KYC. + // + // Both used to be cleared here, which meant a ₹100 deposit quietly did the work the + // branch was supposed to do at the counter. if (backdatedTxnsAllowedTill) { this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, @@ -1512,6 +1520,37 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom throw new PlatformApiDataValidationException(dataValidationErrors); } + // A dormant account does not pay out until someone has looked at it. + // + // Dormancy means nobody has operated the account for the product's dormancy period — two + // years here — and an account nobody has touched for two years is the one an impersonator + // reaches for, because the real customer is not watching it. RBI's Master Direction on KYC + // requires the operations of such an account to be allowed only after the customer's + // identity is established afresh, so the debit is stopped and the account is revived only + // by a completed re-KYC. + // + // Credits are deliberately not stopped: a deposit puts money in, cannot be a fraud against + // the customer, and is the customer coming back. + // + // The elapsed days are counted here rather than trusting the sub-status alone. The nightly + // job is what stamps DORMANT, so between an account passing the threshold and that job next + // running the flag still says the account is fine — and a withdrawal in that window used to + // go straight through. It is a real gap: the job can be behind after an outage, or the + // business date can move by more than a day. + if (isDormantByElapsedDays()) { + final String defaultUserMessage = "Withdrawal is not allowed. The account is dormant — no customer-initiated" + + " transaction for " + dormancyPeriodDescription() + ". It must be reactivated after the customer's" + + " identity is re-verified."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.savingsaccount.transaction.withdrawals.blocked.account.dormant", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { final String defaultUserMessage = "Transaction date cannot be in the future."; final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future", @@ -1570,10 +1609,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom addTransaction(transaction); } - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } + // A withdrawal clears nothing on the dormancy ladder either — see the deposit path for why. if (backdatedTxnsAllowedTill) { this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, this.savingsAccountTransactions); @@ -1581,6 +1617,52 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return transaction; } + /** + * How long the product says an account must sit untouched before it is dormant, for the message + * the customer is shown. Read from the product so the figure quoted is the one actually applied. + */ + private String dormancyPeriodDescription() { + final Long days = this.product == null ? null : this.product.getDaysToDormancy(); + if (days == null || days <= 0) { + return "the product's dormancy period"; + } + final long years = days / 365; + return years >= 1 ? years + (years == 1 ? " year" : " years") : days + " days"; + } + + /** + * Whether this account is dormant, by the sub-status the nightly job stamps or by the days that + * have actually elapsed since the customer last operated it. + * + *

+ * The elapsed count is what closes the window between an account crossing the dormancy + * threshold and the nightly job next running: until that job runs the sub-status still says the + * account is fine, and a withdrawal in that window went straight through. The job can be a day + * behind after an outage, and a business date moved forward by more than a day skips the runs + * in between entirely. + * + *

+ * The count mirrors the job's own query exactly, so the two can never disagree about whether an + * account is dormant: only deposits and withdrawals count as the customer operating the + * account — interest and charges are the bank's own movements, not the customer's — reversed + * transactions are ignored, and an account with no such transaction is measured from its + * activation. Dormancy tracking being switched off on the product turns this off with it. + */ + /** + * Whether this account is dormant, from the sub-status the nightly job stamped. + * + *

+ * The elapsed days are not recounted here, though the read services and the clerk's withdrawal + * check both do. The clock now runs from the customer's last completed re-KYC — see + * DormancyClock — and this module cannot see a client's KYC record, so counting here would use + * transactions and give a different answer from every other place that asks. A guard that + * disagrees with the screen the officer just read is worse than one that lags the job by a + * night, and the clerk's own check catches that gap before a request is ever raised. + */ + private boolean isDormantByElapsedDays() { + return SavingsAccountSubStatusEnum.DORMANT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status)); + } + public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) { BigDecimal result = BigDecimal.ZERO; if (isWithdrawalFeeApplicableForTransfer()) { @@ -4278,6 +4360,31 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return false; } + /** + * Revives a dormant account, on the strength of a completed re-KYC. + * + *

+ * RBI's Master Direction allows a dormant account to be operated again only once the customer's + * identity has been established afresh. A completed re-KYC is that verification, so it is the + * one thing that lifts dormancy — a transaction does not, which is why {@link #withdraw} refuses + * on a dormant account and a deposit no longer revives one. + * + *

+ * Called from the re-KYC completion itself rather than from the nightly expiry sweep: the sweep + * runs over every customer whose KYC is merely current, and reviving there would reopen a + * dormant account on the next nightly run without anyone having verified anything. + * + * @return true if the sub-status was changed + */ + public boolean reviveOnReKyc() { + final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (SavingsAccountSubStatusEnum.DORMANT.hasStateOf(current) || SavingsAccountSubStatusEnum.INACTIVE.hasStateOf(current)) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + return true; + } + return false; + } + public void validateForAccountBlock() { final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml index 78ca69ec5..5c4f2ddf1 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml @@ -293,4 +293,52 @@ + + + + + + + UPDATE m_charge + SET income_or_liability_account_id = NULL + WHERE income_or_liability_account_id = ( + SELECT id FROM acc_gl_account + WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false + ORDER BY id LIMIT 1) + AND name IN ('ATM Facility Charge', 'ATM Withdrawal Charge', 'UPI Facility Charge', + 'POS Transaction Charge', 'Cheque Book Charge', 'IMPS Facility Charge', + 'SMS Alert Monthly Charge', 'BBPS Facility Charge', 'Insurance Cover Charge', + 'BEPG Facility Charge', 'NEFT Facility Charge', 'RTGS Facility Charge', + 'Inter-Bank Cap Charge', 'Withdrawal Facility Charge', 'Deposit KYC Charge', + 'Virtual Debit Card Charge'); + + + diff --git a/sql/fix_stale_kyc_exceptions.sql b/sql/fix_stale_kyc_exceptions.sql new file mode 100644 index 000000000..c9a7f08e8 --- /dev/null +++ b/sql/fix_stale_kyc_exceptions.sql @@ -0,0 +1,73 @@ +-- Closes KYC exceptions left open by verifications that actually passed. +-- +-- The KYC tab wrote the exception columns *before* calling the vendor, when the check was by +-- definition still outstanding, and nothing rewrote them once the check passed. So a client could +-- end up with pan_verified / aadhaar_verified set while is_exception stayed true — the KYC tab +-- read the verified flags and showed "Verified", the exceptions register read is_exception and +-- kept the client in its Pending tab. +-- +-- The frontend no longer leaves records in that state (it re-derives the exception columns on each +-- verification success), but rows written before that fix still carry it. This closes exactly +-- those, using the same rule the application derives: an exception whose every outstanding item +-- has since been settled is resolved. +-- +-- Settled means verified by the vendor, or closed by paperwork — a signed PAN declaration or a +-- UIDAI enrolment receipt — which is what the application counts too. Aadhaar is not applicable to +-- non-individual clients (legal_form_enum = 2), which is why it is excluded rather than treated as +-- an outstanding item that can never pass. +-- +-- exception_reason is deliberately left as-is: the Resolved tab shows what was resolved, so +-- clearing it would erase the record of why the exception was ever raised. +-- +-- Safe to re-run — the WHERE clause matches nothing once the rows are closed. +-- +-- Review first, then run: +-- SELECT ... (the SELECT below) to see what will change +-- then the UPDATE. + +-- ── Review: the rows this will close ───────────────────────────────────────── +SELECT k.id AS kyc_id, + c.id AS client_id, + c.display_name, + k.exception_reason, + k.pan_verified, + k.aadhaar_verified, + k.pan_declaration_taken, + k.aadhaar_receipt_taken, + c.legal_form_enum +FROM m_client_kyc k +JOIN m_client c ON c.id = k.client_id +WHERE k.is_exception = TRUE + AND k.exception_resolved = FALSE + -- Only the two reasons the application manages automatically. A record also carrying an + -- officer-entered reason is left alone: only a human knows whether that item is settled. + AND k.exception_reason IS NOT NULL + AND TRIM(k.exception_reason) IN ( + 'PAN Card Verification Failed', + 'Aadhaar Verification Failed', + 'PAN Card Verification Failed, Aadhaar Verification Failed', + 'Aadhaar Verification Failed, PAN Card Verification Failed' + ) + -- PAN settled: verified, or closed by a signed declaration. + AND (k.pan_verified = TRUE OR COALESCE(k.pan_declaration_taken, FALSE) = TRUE) + -- Aadhaar settled: verified, closed by an enrolment receipt, or not applicable. + AND (k.aadhaar_verified = TRUE OR COALESCE(k.aadhaar_receipt_taken, FALSE) = TRUE OR c.legal_form_enum = 2); + +-- ── Fix ────────────────────────────────────────────────────────────────────── +UPDATE m_client_kyc k +SET is_exception = FALSE, + exception_resolved = TRUE, + exception_resolved_on = CURRENT_DATE +FROM m_client c +WHERE c.id = k.client_id + AND k.is_exception = TRUE + AND k.exception_resolved = FALSE + AND k.exception_reason IS NOT NULL + AND TRIM(k.exception_reason) IN ( + 'PAN Card Verification Failed', + 'Aadhaar Verification Failed', + 'PAN Card Verification Failed, Aadhaar Verification Failed', + 'Aadhaar Verification Failed, PAN Card Verification Failed' + ) + AND (k.pan_verified = TRUE OR COALESCE(k.pan_declaration_taken, FALSE) = TRUE) + AND (k.aadhaar_verified = TRUE OR COALESCE(k.aadhaar_receipt_taken, FALSE) = TRUE OR c.legal_form_enum = 2); From 3d196276c8716d1e79ebff2bc5600ed2338d0576 Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 11 Sep 2026 10:48:50 +0530 Subject: [PATCH 40/55] feat(savings): support rounding adjustment GL mapping and floor rounding on clerk closure --- ...AccountMappingReadPlatformServiceImpl.java | 4 + ...avingsProductToGLAccountMappingHelper.java | 18 +++++ .../common/AccountingConstants.java | 20 ++++- ...ccountMappingWritePlatformServiceImpl.java | 9 +++ .../api/ClerkClosureRequestApiResource.java | 76 +++++++++++++++---- .../data/SavingsProductDataValidator.java | 10 +++ 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java index 859a64399..8c6ed8881 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java @@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); } else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) { accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); + } else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) { + accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData); // Expense } else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) { accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); @@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); } else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) { accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); + } else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) { + accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData); // Expense } else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) { accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index fd77457cf..2fa7358c0 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa PortfolioProductType.SAVING); } + /** + * Create-or-merge, for an expense row a product may not have yet. + * + * The plain merge variant assumes the mapping already exists, which is true of every expense + * row that shipped with the product screen. A row added later — the rounding adjustment — is + * absent on every product created before it, so editing one of those has to create the + * mapping rather than fail looking for it. + */ + public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName, + final Long productId, final int accountTypeId, final Map changes) { + createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE, + PortfolioProductType.SAVING); + } + /*** Abstractions for payments channel related to savings products ***/ public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId, @@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes); createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); + createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), + savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); break; case ACCRUAL_PERIODIC: // asset @@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); + createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), + savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); break; case ACCRUAL_UPFRONT: diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index e79c7d784..655527918 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -269,7 +269,15 @@ public final class AccountingConstants { OVERDRAFT_PORTFOLIO_CONTROL(11), // INCOME_FROM_INTEREST(12), // LOSSES_WRITTEN_OFF(13), // - ESCHEAT_LIABILITY(14); // + ESCHEAT_LIABILITY(14), // + /** + * Where the paise dropped from a cash closure are absorbed. + * + * Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as + * 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered + * well clear of the accrual slots so the two sets never collide as either grows. + */ + ROUNDING_ADJUSTMENT(26); // private final Integer value; @@ -317,7 +325,9 @@ public final class AccountingConstants { FEES_RECEIVABLE(15), // PENALTIES_RECEIVABLE(16), // INTEREST_PAYABLE(17), // - INTEREST_RECEIVABLE(18); + INTEREST_RECEIVABLE(18), // + /** Where the paise dropped from a cash closure are absorbed — see the cash enum. */ + ROUNDING_ADJUSTMENT(26); private final Integer value; @@ -372,7 +382,8 @@ public final class AccountingConstants { PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), // INTEREST_RECEIVABLE("interestReceivableAccountId"), // FEES_RECEIVABLE("feesReceivableAccountId"), // - INTEREST_PAYABLE("interestPayableAccountId"); + INTEREST_PAYABLE("interestPayableAccountId"), // + ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId"); private final String value; @@ -410,7 +421,8 @@ public final class AccountingConstants { FEES_RECEIVABLE("feeReceivableAccount"), // PENALTIES_RECEIVABLE("penaltyReceivableAccount"), // INTEREST_PAYABLE("interestPayableAccount"), // - INTEREST_RECEIVABLE("interestReceivableAccount"); // + INTEREST_RECEIVABLE("interestReceivableAccount"), // + ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); // private final String value; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index 5e29e5ca1..35af580f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); } + // Optional: only mapped when the product screen supplies it. Every savings product that + // existed before this row was added has none, and a closure on one of those still has to + // work — see the closure's own fallback. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) { + this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element, + SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId, + CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue()); + } + // advanced accounting mappings this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null); this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index cfb71fca7..65056173c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -76,10 +76,13 @@ import org.springframework.transaction.annotation.Transactional; * Close Savings Account screen), and a cashier hands over the physical cash against the token, * recording the denomination breakdown. * - * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the - * nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated - * "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own - * ledger (which stays exactly correct to the paisa). + * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded DOWN + * to the whole rupee for the physical hand-over -- 9,883.79 is paid as 9,883 -- and the dropped paise + * are absorbed by the Rounding Adjustment account the savings product names, via one small adjusting + * journal entry. The member's own ledger stays exactly correct to the paisa. + * + * Down rather than to the nearest rupee, so the branch never pays out more cash than the account + * actually held: rounding 9,883.79 up to 9,884 hands over 21 paise the member never had. */ @Path("/v1/clerk/closure-requests") @Component @@ -91,6 +94,13 @@ public class ClerkClosureRequestApiResource { // and a field of the same name reads as that variable at a glance. private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class); + /** acc_product_mapping.product_type for a savings product. */ + private static final int PRODUCT_TYPE_SAVING = 2; + /** {@code CashAccountsForSavings.ROUNDING_ADJUSTMENT} */ + private static final int ROUNDING_ADJUSTMENT_ACCOUNT_TYPE = 26; + /** Where the paise go on a product that has no mapping of its own yet. */ + private static final String DEFAULT_ROUND_OFF_GL_CODE = "501200"; + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -170,7 +180,7 @@ public class ClerkClosureRequestApiResource { final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate); // Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING, // exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure. - final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR); final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -316,7 +326,7 @@ public class ClerkClosureRequestApiResource { res.addProperty("total", total); // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). - res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("roundedTotal", total.setScale(0, RoundingMode.FLOOR)); res.addProperty("interestUpToDate", true); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); @@ -534,12 +544,12 @@ public class ClerkClosureRequestApiResource { if (txn != null) { txnId = txn.get("id").toString(); closingAmount = new BigDecimal(txn.get("amount").toString()); - cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR); roundingAmount = cashierDisbursedAmount.subtract(closingAmount); if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) { final Long officeId = ((Number) txn.get("office_id")).longValue(); - postRoundingAdjustment(officeId, roundingAmount, currentUser); + postRoundingAdjustment(officeId, roundingAmount, currentUser, savingsAccountId); } } @@ -795,11 +805,11 @@ public class ClerkClosureRequestApiResource { lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); if (roundingDirection > 0) { - lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); } lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); if (roundingDirection < 0) { - lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); } final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, @@ -824,9 +834,10 @@ public class ClerkClosureRequestApiResource { * extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the * difference as an expense). Rounded down, both legs flip. */ - private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) { + private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser, + final Long savingsAccountId) { final Map cashGl = resolveCashGlAccount(); - final Long roundOffGlId = resolveRoundOffGlAccount(); + final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId); final String currencyCode = jdbcTemplate.queryForObject( "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); final BigDecimal amount = roundingAmount.abs(); @@ -839,10 +850,15 @@ public class ClerkClosureRequestApiResource { // type_enum: 2 = DEBIT, 1 = CREDIT. if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) { // Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash + // + // Unreachable while the hand-over rounds down, and kept so: an amount already whole to + // the rupee produces no adjustment at all, and if the rounding rule is ever changed + // back this leg is still correct rather than silently posting the wrong way. insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); } else { - // Rounded down: less cash left the drawer than the member's ledger gave up. + // Rounded down, which is now every case: the member's ledger gave up the full paisa + // amount but less cash left the drawer, so the paise stay with the bank. // Dr Cash / Cr Round Off insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); @@ -862,11 +878,39 @@ public class ClerkClosureRequestApiResource { return rows.get(0); } - private Long resolveRoundOffGlAccount() { + /** + * The GL the dropped paise are absorbed by, from the closing account's own savings product. + * + *

+ * Read from the product's Accounting tab like every other GL this closure touches, so a bank + * on its own chart of accounts posts to the head it chose. This used to be + * {@code gl_code = '501200'} written into the query, which meant a tenant whose chart has no + * such code could not close an account at all, and a bank that keeps its round-off elsewhere + * had to create a 501200 anyway. + * + *

+ * Falls back to that code where the product has no mapping yet — every product created before + * the row existed is in that position, and a closure must not fail over a few paise. + */ + private Long resolveRoundOffGlAccount(final Long savingsAccountId) { + if (savingsAccountId != null) { + final List mapped = jdbcTemplate.queryForList(""" + SELECT m.gl_account_id + FROM acc_product_mapping m + JOIN m_savings_account sa ON sa.product_id = m.product_id + WHERE sa.id = ? AND m.product_type = ? AND m.financial_account_type = ? + LIMIT 1 + """, Long.class, savingsAccountId, PRODUCT_TYPE_SAVING, ROUNDING_ADJUSTMENT_ACCOUNT_TYPE); + if (!mapped.isEmpty()) { + return mapped.get(0); + } + } final List ids = jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class); + "SELECT id FROM acc_gl_account WHERE gl_code = ? LIMIT 1", Long.class, DEFAULT_ROUND_OFF_GL_CODE); if (ids.isEmpty()) { - throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet."); + throw apiError(500, "No Rounding Adjustment account is mapped on the savings product, and the default " + + "Round Off Account (gl_code " + DEFAULT_ROUND_OFF_GL_CODE + ") does not exist in this tenant. " + + "Map one on the product's Accounting tab."); } return ids.get(0); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java index c3502162f..8ee4f3417 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java @@ -105,6 +105,7 @@ public class SavingsProductDataValidator { SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), + SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), isDormancyTrackingActiveParamName, daysToDormancyParamName, daysToInactiveParamName, daysToEscheatParamName, allowOverdraftParamName, overdraftLimitParamName, nominalAnnualInterestRateOverdraftParamName, minOverdraftForInterestCalculationParamName, SavingsApiConstants.minRequiredBalanceParamName, @@ -460,6 +461,15 @@ public class SavingsProductDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue()) .value(escheatLiabilityAccountId).notNull().integerGreaterThanZero(); } + + // Optional — a product saved without one still closes, on the fallback the closure + // carries. Only the shape is checked, so clearing the field is allowed. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) { + final Long roundingAdjustmentAccountId = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue()) + .value(roundingAdjustmentAccountId).ignoreIfNull().integerGreaterThanZero(); + } } validateOverdraftParams(baseDataValidator, element); From c20a7d072805e9f2f91e3d52500009573ec8fa57 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 11 Sep 2026 15:07:13 +0530 Subject: [PATCH 41/55] feat(security): tenant-level nav menu access control, independent of role permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-tenant ceiling on which of the 27 top-nav sections a bank can ever show, closing a real gap the "Clerk" role's ALL_FUNCTIONS misconfiguration exposed this session: HasPermissionDirective already hid a menu a user's own role lacked, but any role carrying ALL_FUNCTIONS bypassed that check unconditionally, so a bank's own super user (or a role accidentally granted ALL_FUNCTIONS) could always see everything regardless of intent. New table m_tenant_menu_access (0541), one row per menu code, seeded enabled for every existing tenant so nothing already deployed changes behaviour. New TenantMenuAccessApiResource: - GET /v1/tenant-menu-access — dual-mode (session or X-Api-Key), same posture as ClientAllAccountsApiResource. A tenant's own users read their own list to render their nav; the platform operator reads any tenant's list with the key, to know current state before toggling it. - GET /v1/tenant-menu-access/tenants — API key only, the master tenant list for the operator's own tenant picker. - PUT /v1/tenant-menu-access — API key only, no session fallback at all, even for a super user. This is the actual security boundary: a bank's own admin has no path to lift its own ceiling, because there is nothing inside their own tenant that can satisfy this endpoint's auth requirement. 0542 splits two nav items that had been sharing a permission code with their neighbour — Loan Origination rode on MENU_ADVANCES, Investment rode on MENU_RBI — into their own MENU_LOAN_ORIGINATION / MENU_INVESTMENT codes, so all 27 top-nav items are now independently controllable. Every role that already held the shared code is granted the new one in the same migration, so this changes nothing about who sees what today. SecurityConfig: permitAll for the two API-key-only paths, same posture as the existing clientaccounts carve-out — the handler itself is what actually authenticates the caller, not Spring Security's outer gate. --- .../core/config/SecurityConfig.java | 9 + .../TenantMenuAccessApiResource.java | 214 ++++++++++++++++++ .../tenant/final-changelog-tenant.xml | 2 + .../parts/0541_create_tenant_menu_access.xml | 64 ++++++ ...oan_origination_investment_permissions.xml | 77 +++++++ 5 files changed, 366 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 7085dc8d1..ca53139c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -172,6 +172,15 @@ public class SecurityConfig { // IP allowlist) — a request with no key and no Fineract login still gets nothing, // it just fails inside the handler instead of at this filter. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll() + // Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above + // for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary + // session when no key is sent, same as ClientAllAccountsApiResource does; permitAll + // here only opens Spring Security's outer gate for the key path, it does not skip + // the handler's own check). The PUT has no session fallback inside the handler at + // all, even for a logged-in super user — see TenantMenuAccessApiResource#save. + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll() // Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed, // no Fineract auth required. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll() diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java new file mode 100644 index 000000000..1bff751e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.security.apiclient; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside + * that tenant grants itself. + * + * {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks -- + * but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it, + * as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally. + * A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on + * "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in + * that tenant, today or after some future role edit, would still see it. This table is the ceiling + * that check now also has to clear. + * + * Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own + * tenant's list to render their nav correctly, the same way they already read their own permissions, + * and the platform operator needs to read ANY tenant's current list before toggling it. Writing it + * accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential -- + * the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no + * session fallback at all here. That is deliberate -- a bank's own super user must never be able to + * lift its own ceiling by granting themselves a permission from inside their own tenant, which is + * exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via + * {@code POST /v1/apiclients} by the platform operator and never handed to a bank. + */ +@Path("/v1/tenant-menu-access") +@Component +@Tag(name = "Tenant Menu Access", + description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.") +@RequiredArgsConstructor +public class TenantMenuAccessApiResource { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final ApiClientCredentialService apiClientCredentialService; + private final TenantDetailsService tenantDetailsService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" }; + + // The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this + // list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a + // code outside this set cannot be created by the API, so a typo in a request body is silently a + // no-op rather than junk data the frontend would then have to guard against. + private static final Set KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS", + "MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES", + "MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING", + "MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT", + "MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT"); + + /** + * This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}. + * Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in + * user reads their own tenant's list exactly like they already read their own permission list + * (no elevated permission required), or the platform operator reads ANY tenant's list with the + * API key, to know the current state before toggling it on their own control screen. Only the + * write side ({@link #save}) has no session fallback -- reading configuration is not sensitive, + * changing it is. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + if (StringUtils.isNotBlank(apiKey)) { + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } else { + context.authenticatedUser(); + } + final List> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access"); + final JsonArray out = new JsonArray(); + for (final Map row : rows) { + final JsonObject o = new JsonObject(); + o.addProperty("menuCode", (String) row.get("menu_code")); + o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled"))); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Every tenant configured on this platform, for the operator's own tenant picker -- always the + * master list from the tenant-store database, never the one tenant the caller's own + * {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name + * a real tenant for the request to reach this handler at all (see + * {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here. + */ + @GET + @Path("/tenants") + @Produces(MediaType.APPLICATION_JSON) + public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) { + requireApiKey(apiKey, servletRequest); + final JsonArray out = new JsonArray(); + for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) { + final JsonObject o = new JsonObject(); + o.addProperty("identifier", tenant.getTenantIdentifier()); + o.addProperty("name", tenant.getName()); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the + * operator's own control screen. API key only -- see the class comment for why a normal session, + * even a super user's, is never accepted here. + * + * Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in + * {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than + * rejected, so the operator's screen can always send its full known set without first checking + * this deployment's exact backend version. + */ + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest, + final String body) { + requireApiKey(apiKey, servletRequest); + + final JsonArray entries = gson.fromJson(body, JsonArray.class); + if (entries == null) { + throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}."); + } + final Set touched = new LinkedHashSet<>(); + for (final var el : entries) { + final JsonObject entry = el.getAsJsonObject(); + if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) { + continue; + } + final String menuCode = entry.get("menuCode").getAsString(); + if (!KNOWN_MENU_CODES.contains(menuCode)) { + continue; + } + final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean(); + jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode); + touched.add(menuCode); + } + final JsonObject res = new JsonObject(); + res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier()); + res.addProperty("updated", touched.size()); + return gson.toJson(res); + } + + /** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */ + private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) { + if (StringUtils.isBlank(apiKey)) { + throw apiError(403, "This endpoint is only reachable with a platform operator API key."); + } + apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest)); + } + + private String resolveClientIp(final HttpServletRequest request) { + for (final String header : IP_HEADER_CANDIDATES) { + final String value = request.getHeader(header); + if (StringUtils.isNotBlank(value)) { + return value.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + return new WebApplicationException( + Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 6b737c847..15554ba53 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -67,4 +67,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml new file mode 100644 index 000000000..45c7773cc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0541_create_tenant_menu_access.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_tenant_menu_access + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml new file mode 100644 index 000000000..991ad7825 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0542_split_loan_origination_investment_permissions.xml @@ -0,0 +1,77 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_LOAN_ORIGINATION' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MENU_INVESTMENT' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p2.id + FROM m_role_permission rp + JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_ADVANCES' + JOIN m_permission p2 ON p2.code = 'MENU_LOAN_ORIGINATION' + WHERE NOT EXISTS ( + SELECT 1 FROM m_role_permission existing + WHERE existing.role_id = rp.role_id AND existing.permission_id = p2.id) + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p2.id + FROM m_role_permission rp + JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_RBI' + JOIN m_permission p2 ON p2.code = 'MENU_INVESTMENT' + WHERE NOT EXISTS ( + SELECT 1 FROM m_role_permission existing + WHERE existing.role_id = rp.role_id AND existing.permission_id = p2.id) + + + + + + + + + SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_LOAN_ORIGINATION' + + + + + + From 9e0c04583d4ea081f041d925bbe1b44fe8b242c0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sat, 12 Sep 2026 13:54:56 +0530 Subject: [PATCH 42/55] fix(roles): remove unused roles, merge managers, revoke Clerk's ALL_FUNCTIONS Audited role usage/permissions across all tenants and cleaned up several inconsistencies: - Removed 'Self Service User' (zero users, no wired-up purpose in the app). - Merged 'As Manager' into 'Branch Manager' (union of both permission sets, users reassigned, As Manager dropped) since the two covered overlapping ground with no clear split. - Revoked Clerk's ALL_FUNCTIONS grant: it unconditionally bypassed every individual permission check (HasPermissionDirective), so unticking any menu/permission for Clerk in Roles & Permissions had no effect. Clerk's own explicit permission rows are untouched and remain the effective set. - Renamed 'CASHIER' to 'Cashier' for display consistency with every other role name; the role-name check this touches (CashierRoleHelper) is case-insensitive, so behavior is unchanged. ('Principal Officer' was audited too but kept - it's the statutory PMLA/RBI compliance seat from 0435, just not yet assigned to anyone.) Each change was applied directly to all three tenant DBs and captured as a migration (0543, 0546, 0547), dry-run verified as a no-op against the now-updated DBs so it applies cleanly here and correctly for any new tenant. --- .../tenant/final-changelog-tenant.xml | 5 ++ ...e_unused_roles_and_merge_manager_roles.xml | 60 +++++++++++++++++++ ...0546_rename_cashier_role_to_title_case.xml | 16 +++++ .../0547_revoke_all_functions_from_clerk.xml | 19 ++++++ 4 files changed, 100 insertions(+) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0543_remove_unused_roles_and_merge_manager_roles.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_rename_cashier_role_to_title_case.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_revoke_all_functions_from_clerk.xml diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 15554ba53..02bfb8312 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -69,4 +69,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0543_remove_unused_roles_and_merge_manager_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0543_remove_unused_roles_and_merge_manager_roles.xml new file mode 100644 index 000000000..881b798cf --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0543_remove_unused_roles_and_merge_manager_roles.xml @@ -0,0 +1,60 @@ + + + + + + + DELETE FROM m_role_permission + WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User') + + + DELETE FROM m_appuser_role + WHERE role_id IN (SELECT id FROM m_role WHERE name = 'Self Service User') + + + DELETE FROM m_role WHERE name = 'Self Service User' + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT (SELECT id FROM m_role WHERE name = 'Branch Manager'), rp.permission_id + FROM m_role_permission rp + WHERE rp.role_id = (SELECT id FROM m_role WHERE name = 'As Manager') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission existing + WHERE existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager') + AND existing.permission_id = rp.permission_id) + + + INSERT INTO m_appuser_role (appuser_id, role_id) + SELECT ur.appuser_id, (SELECT id FROM m_role WHERE name = 'Branch Manager') + FROM m_appuser_role ur + WHERE ur.role_id = (SELECT id FROM m_role WHERE name = 'As Manager') + AND NOT EXISTS ( + SELECT 1 FROM m_appuser_role existing + WHERE existing.appuser_id = ur.appuser_id + AND existing.role_id = (SELECT id FROM m_role WHERE name = 'Branch Manager')) + + + DELETE FROM m_appuser_role WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager') + + + DELETE FROM m_role_permission WHERE role_id = (SELECT id FROM m_role WHERE name = 'As Manager') + + + DELETE FROM m_role WHERE name = 'As Manager' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_rename_cashier_role_to_title_case.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_rename_cashier_role_to_title_case.xml new file mode 100644 index 000000000..b2ffb76e2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_rename_cashier_role_to_title_case.xml @@ -0,0 +1,16 @@ + + + + + + + UPDATE m_role SET name = 'Cashier' WHERE name = 'CASHIER' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_revoke_all_functions_from_clerk.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_revoke_all_functions_from_clerk.xml new file mode 100644 index 000000000..519b7261a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_revoke_all_functions_from_clerk.xml @@ -0,0 +1,19 @@ + + + + + + + DELETE FROM m_role_permission + WHERE role_id = (SELECT id FROM m_role WHERE name = 'Clerk') + AND permission_id = (SELECT id FROM m_permission WHERE code = 'ALL_FUNCTIONS') + + + From 04ac233fbea1f5f4191cfbae7d4628e03ed13032 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 13 Sep 2026 15:22:07 +0530 Subject: [PATCH 43/55] fix: share collection GL lookup, tenant chart-of-accounts cleanup, and dormancy/date fixes - Add balancesheet API, GL account transaction guard, and disabled-feature filter - Recognize an approved Clerk Disbursement Request (COLLECTION direction) as a second valid source when resolving a Share Advance Collection voucher, not just a Cashier Transaction Entry deposit - Fix a NullPointerException in ShareAccount.getShareAccountTransaction() when an existing charge transaction has no total-shares/purchase-price recorded - Null out DormancyClock's "last live" date before an account is ever activated, instead of falling through to the client's own KYC re-verification date via Postgres's null-skipping GREATEST() - Stop seeding "Cash at Tellers" for new tenants (0502); drop the unused Facility & Service Charges Income accounts (0548); group header-less GL accounts under real-world header accounts (0549); uppercase GL account names and rename Share Sundry to match the bank's own chart (0550) - Fix 0527's savings-daily-interest changeset to skip objects 0517 already creates on a fresh tenant, instead of failing tenant startup - Update the local Postgres dev password default to match this environment Co-Authored-By: Claude Sonnet 5 --- .../JournalEntryInvalidException.java | 5 + .../api/BalanceSheetApiResource.java | 243 ++++++++++++++++++ .../validation/GLAccountTransactionGuard.java | 58 +++++ ...WritePlatformServiceJpaRepositoryImpl.java | 68 +++++ .../service/IncomeProfitTransferService.java | 2 + .../core/config/SecurityConfig.java | 10 +- .../filters/DisabledFeatureApiFilter.java | 128 +++++++++ .../api/BranchDayRegisterApiResource.java | 45 ++++ .../ShareAdvanceCollectionApiResource.java | 89 ++++++- .../api/ShareCollectionApiResource.java | 2 + .../api/ClerkClosureRequestApiResource.java | 2 + ...lerkShareWithdrawalRequestApiResource.java | 2 + .../api/DividendClaimRequestApiResource.java | 3 + .../SavingsPooledInterestPostingService.java | 2 + .../savings/service/DormancyClock.java | 19 +- .../shareaccounts/domain/ShareAccount.java | 16 +- .../AdditionalShareTrancheFundingService.java | 2 + .../ShareAccountSchedularServiceImpl.java | 3 + ...WritePlatformServiceJpaRepositoryImpl.java | 3 + .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 3 + .../0502_backfill_core_chart_of_accounts.xml | 11 +- ...ngs_daily_interest_one_row_per_account.xml | 32 ++- .../parts/0544_catchup_teller_is_deleted.xml | 41 +++ .../0545_catchup_current_account_columns.xml | 53 ++++ ...unused_facility_charge_income_accounts.xml | 49 ++++ ...group_orphan_gl_accounts_under_headers.xml | 134 ++++++++++ .../parts/0550_uppercase_gl_account_names.xml | 50 ++++ 28 files changed, 1059 insertions(+), 20 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java index ac1a7ee77..6a810446f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/journalentry/exception/JournalEntryInvalidException.java @@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, // GL_ACCOUNT_DISABLED, // GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, // + GL_ACCOUNT_IS_HEADER, // INVALID_DEBIT_OR_CREDIT_ACCOUNTS; // public String errorMessage() { @@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce return "Invalid debit or credit accounts are passed"; } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { return "Target account does not allow manual adjustments"; + } else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) { + return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly"; } return name(); } @@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts"; } else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) { return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted"; + } else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) { + return "error.msg.glJournalEntry.invalid.account.is.header"; } return name(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java new file mode 100644 index 000000000..82333046a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/balancesheet/api/BalanceSheetApiResource.java @@ -0,0 +1,243 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.balancesheet.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * An "as on date" balance sheet built directly off the live chart of accounts and journal entries, + * rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise + * layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit + * Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header) + * children indented underneath it and a subtotal on the block itself. A Detail account with no + * Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone) + * still needs to appear somewhere, so it is listed as its own root-level line rather than silently + * dropped. + * + * A Header account never itself carries a journal entry (see the header/detail discussion this + * feature grew out of), so its balance is always the recursive sum of its children - never read off + * acc_gl_journal_entry directly. + */ +@Path("/v1/accounting/balancesheet") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class BalanceSheetApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new Gson(); + + // classification_enum values (see acc_gl_account / GLAccountType). + private static final int ASSET = 1; + private static final int LIABILITY = 2; + private static final int EQUITY = 3; + private static final int INCOME = 4; + private static final int EXPENSE = 5; + // account_usage values (see GLAccountUsage). + private static final int DETAIL = 1; + private static final int HEADER = 2; + + private static final class Node { + + Long id; + String name; + String glCode; + int usage; + int classification; + Long parentId; + BigDecimal ownBalance = BigDecimal.ZERO; + final List children = new ArrayList<>(); + + BigDecimal balance() { + if (usage == DETAIL) { + return ownBalance; + } + BigDecimal sum = BigDecimal.ZERO; + for (final Node child : children) { + sum = sum.add(child.balance()); + } + return sum; + } + + Map toJson() { + final Map json = new LinkedHashMap<>(); + json.put("id", id); + json.put("name", name); + json.put("glCode", glCode); + json.put("usage", usage == HEADER ? "HEADER" : "DETAIL"); + json.put("balance", balance()); + final List> childJson = new ArrayList<>(); + for (final Node child : children) { + childJson.add(child.toJson()); + } + json.put("children", childJson); + return json; + } + } + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate() + : LocalDate.parse(asOnDateParam); + + final Map nodesById = new LinkedHashMap<>(); + for (final Map row : this.jdbcTemplate.queryForList( + "SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account " + + "WHERE disabled = false ORDER BY gl_code")) { + final Node node = new Node(); + node.id = ((Number) row.get("id")).longValue(); + node.name = (String) row.get("name"); + node.glCode = (String) row.get("gl_code"); + node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue(); + node.usage = ((Number) row.get("account_usage")).intValue(); + node.classification = ((Number) row.get("classification_enum")).intValue(); + nodesById.put(node.id, node); + } + + // entry_date (not transaction_date) and "reversed = false" are this codebase's settled + // convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend + // reports and IncomeProfitTransferService all cut the same way, so this report agrees with + // them rather than quietly producing a second set of numbers. + // + // The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This + // codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the + // flag on the original leg (no counter entry - excluding it is exactly right), while + // Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and + // links it back through reversal_id. For that second kind, dropping only the original would + // leave its counter entry standing alone and swing the balance by the full amount in the + // wrong direction, so the counter entry has to go too. + final Map balances = new LinkedHashMap<>(); + final String balanceSql = "SELECT account_id, " + + "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, " + + "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit " + + "FROM acc_gl_journal_entry WHERE entry_date <= ? " + + "AND (reversed IS NULL OR reversed = false) " + + "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)" + + (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id"; + final List> balanceRows = officeId != null + ? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId) + : this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate)); + for (final Map row : balanceRows) { + balances.put(((Number) row.get("account_id")).longValue(), + new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") }); + } + + for (final Node node : nodesById.values()) { + if (node.usage != DETAIL) { + continue; + } + final BigDecimal[] debitCredit = balances.get(node.id); + if (debitCredit == null) { + continue; + } + final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE; + node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]); + } + + final List roots = new ArrayList<>(); + for (final Node node : nodesById.values()) { + if (node.parentId == null) { + roots.add(node); + } else { + final Node parent = nodesById.get(node.parentId); + if (parent != null) { + parent.children.add(node); + } else { + // Parent was disabled (filtered out above) - surface this node at the top level + // rather than silently losing its balance from the report. + roots.add(node); + } + } + } + + final Map debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE); + final Map creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME); + + final Map response = new LinkedHashMap<>(); + response.put("asOnDate", asOnDate.toString()); + response.put("officeId", officeId); + response.put("debitSide", debitSide); + response.put("creditSide", creditSide); + return this.gson.toJson(response); + } + + private Map buildSide(final String label, final List roots, final int... classifications) { + final List> groups = new ArrayList<>(); + BigDecimal sideTotal = BigDecimal.ZERO; + for (final int classification : classifications) { + final List> nodeJsons = new ArrayList<>(); + BigDecimal groupTotal = BigDecimal.ZERO; + for (final Node root : roots) { + if (root.classification != classification) { + continue; + } + nodeJsons.add(root.toJson()); + groupTotal = groupTotal.add(root.balance()); + } + if (!nodeJsons.isEmpty()) { + final Map group = new LinkedHashMap<>(); + group.put("classification", classificationLabel(classification)); + group.put("total", groupTotal); + group.put("nodes", nodeJsons); + groups.add(group); + sideTotal = sideTotal.add(groupTotal); + } + } + final Map side = new LinkedHashMap<>(); + side.put("label", label); + side.put("total", sideTotal); + side.put("groups", groups); + return side; + } + + private String classificationLabel(final int classification) { + return switch (classification) { + case ASSET -> "ASSET"; + case LIABILITY -> "LIABILITY"; + case EQUITY -> "EQUITY"; + case INCOME -> "INCOME"; + case EXPENSE -> "EXPENSE"; + default -> "OTHER"; + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java new file mode 100644 index 000000000..08b7cea5a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.glaccount.validation; + +import org.apache.fineract.accounting.glaccount.domain.GLAccountUsage; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * A Header GL account (account_usage = HEADER) is a pure grouping node in the chart-of-accounts tree - it + * exists to organize Detail accounts underneath it, never to receive a transaction itself. The standard + * Fineract journal-entry command path (JournalEntryWritePlatformServiceJpaRepositoryImpl) never checked + * this - only "disabled" and "manual entries allowed" - and several features in this codebase post journal + * entries via direct JDBC INSERT into acc_gl_journal_entry, bypassing that command path entirely. This is + * the one shared check every such call site uses so a Header account can never end up with its own + * transaction (which would silently break every report that assumes a Header's balance is just the sum of + * its Detail children). + */ +public final class GLAccountTransactionGuard { + + private GLAccountTransactionGuard() {} + + public static void assertPostable(final JdbcTemplate jdbcTemplate, final Long glAccountId) { + if (glAccountId == null) { + return; + } + final Integer usage; + try { + usage = jdbcTemplate.queryForObject("SELECT account_usage FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.glaccount.not.found", + "GL account with id " + glAccountId + " does not exist.", "glAccountId", glAccountId); + } + if (GLAccountUsage.HEADER.getValue().equals(usage)) { + throw new PlatformDataIntegrityException("error.msg.glaccount.header.account.not.postable", + "GL account " + glAccountId + " is a Header account (a chart-of-accounts grouping node) and cannot receive a " + + "transaction directly. Select a Detail account instead.", + "glAccountId", glAccountId); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 2f79ebae8..195582cf4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -21,6 +21,7 @@ package org.apache.fineract.accounting.journalentry.service; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; @@ -391,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa } else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) { throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null, creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode()); + } else if (creditOrDebitAccountHead.isHeaderAccount()) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null, + creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode()); } } @@ -775,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue()); validateBusinessRulesForJournalEntries(journalEntryCommand); + validateNoDenominationManagedCashAccounts(journalEntryCommand); /** * revert old journal entries + * + * A transaction that carries a leg against the denomination-managed cash account (see + * validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is + * tied to this same office+contra pairing (Migrate Cash Denomination posts its opening + * entry against this same Opening Balances Contra account), so the query above finds it + * too. Reversing it here, while the new submission is barred from ever re-posting a cash + * leg, would silently erase the cash account's opening balance every time an admin + * redefines opening balances for any OTHER account at this office. */ + final List denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds(); final List transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId, officeId); for (String transactionId : transactionIdsToBeReversed) { final List journalEntries = this.glJournalEntryRepository .findUnReversedManualJournalEntriesByTransactionId(transactionId); + final boolean touchesDenominationManagedCashAccount = journalEntries.stream() + .anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId())); + if (touchesDenominationManagedCashAccount) { + continue; + } revertJournalEntry(journalEntries, "defining opening balance"); } @@ -865,6 +884,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa } } + /** + * The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared + * by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly + * which account(s) Migrate Cash Denomination owns. + */ + private List resolveDenominationManagedCashGlAccountIds() { + final List ids = new ArrayList<>(); + for (final FinancialActivityAccount mapping : this.financialActivityAccountRepositoryWrapper.findAll()) { + final Integer type = mapping.getFinancialActivityType(); + if ((type != null && (type == 101 || type == 102)) && mapping.getGlAccount() != null) { + ids.add(mapping.getGlAccount().getId()); + } + } + return ids; + } + + /** + * The GL account(s) mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) are + * managed exclusively through Migrate Cash Denomination (BranchDayRegisterApiResource#migrateCashDenomination), + * which keeps the GL figure tied to an actual counted note-and-coin breakdown, and validates any + * redefinition against that same count. Defining or redefining that account's opening balance here + * instead - as a bare number, with no denomination breakdown behind it - would let the GL figure + * drift away from what is physically in the till, with nothing left to catch the mismatch. So this + * command refuses to touch either of those accounts at all, on either side of the entry, rather than + * only when the amount would differ from what is already posted: the physical count is the only + * source of truth for this account, never a number typed into a generic opening-balance grid. + */ + private void validateNoDenominationManagedCashAccounts(final JournalEntryCommand command) { + final List denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds(); + if (denominationManagedGlAccountIds.isEmpty()) { + return; + } + final List allEntries = new ArrayList<>(); + if (command.getDebits() != null) { + allEntries.addAll(Arrays.asList(command.getDebits())); + } + if (command.getCredits() != null) { + allEntries.addAll(Arrays.asList(command.getCredits())); + } + for (final SingleDebitOrCreditEntryCommand entry : allEntries) { + if (denominationManagedGlAccountIds.contains(entry.getGlAccountId())) { + throw new GeneralPlatformDomainRuleException("error.msg.journalentry.opening.balance.cash.account.locked", + "This account is the tenant's Cash at Vault / Cash at Teller account and is managed through " + + "Migrate Cash Denomination - it cannot be set or changed from Migrate Opening Balances.", + entry.getGlAccountId()); + } + } + } + @Override public void createJournalEntriesForClientTransactions(Map accountingBridgeData) { final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 464ddee50..18d5cf7db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.dao.EmptyResultDataAccessException; @@ -359,6 +360,7 @@ public class IncomeProfitTransferService { private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + " entry_date, type_enum, amount, description, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index ca53139c8..b918422a1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter; import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter; +import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter; import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper; import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter; @@ -426,7 +427,10 @@ public class SecurityConfig { .addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class) .addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class) .addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class) - .addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class); + .addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class) + // Refuses the handful of accounting features switched off for this installation. + // Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why. + .addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class); if (loanCOBFilterHelper != null) { http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(), @@ -463,6 +467,10 @@ public class SecurityConfig { return new RequestResponseFilter(); } + public DisabledFeatureApiFilter disabledFeatureApiFilter() { + return new DisabledFeatureApiFilter(); + } + public LoanCOBApiFilter loanCOBApiFilter() { return new LoanCOBApiFilter(loanCOBFilterHelper); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java new file mode 100644 index 000000000..cc5d2bd54 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/filters/DisabledFeatureApiFilter.java @@ -0,0 +1,128 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.filters; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Turns off a handful of accounting features at the HTTP edge, without deleting any of the code + * behind them: the resources, services and command handlers all stay exactly as they are, and only + * an inbound API call is refused. Re-enabling a feature is deleting its rule from RULES below. + * + * Blocking here rather than inside the resources matters for one specific reason: several internal + * flows raise journal entries through CommandWrapperBuilder().createJournalEntry() in Java + * (ClerkDisbursementRequestApiResource's GL offsets, clerk closure rounding, bank transfers). Those + * never travel over HTTP, so they keep working, while the "Create Journal Entries" screen and any + * direct call to the endpoint are refused. + * + * A rule carries an optional set of allowed "command" query parameter values, because + * POST /v1/journalentries is three different features behind one path: no command is the manual + * journal entry screen (blocked), command=defineOpeningBalance is Migrate Opening Balances and + * command=updateRunningBalance is the running-balance maintenance job (both still needed). + */ +public class DisabledFeatureApiFilter extends OncePerRequestFilter { + + private record Rule(String feature, Set methods, Pattern path, Set allowedCommands) { + + boolean matches(final String method, final String pathInfo, final String command) { + if (!methods.isEmpty() && !methods.contains(method)) { + return false; + } + if (!path.matcher(pathInfo).matches()) { + return false; + } + // A command this rule explicitly spares (a different feature sharing the same path). + return !(command != null && allowedCommands.contains(command)); + } + } + + private static final List RULES = List.of( + // Create Journal Entries, and Frequent Postings which posts through the same endpoint. + new Rule("Create Journal Entries", Set.of("POST"), Pattern.compile("/v[1-9][0-9]*/journalentries/?"), + Set.of("defineOpeningBalance", "updateRunningBalance")), + // Closing Entries. + new Rule("Closing Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/glclosures(/.*)?"), Set.of()), + // Accruals. + new Rule("Accruals", Set.of(), Pattern.compile("/v[1-9][0-9]*/runaccruals(/.*)?"), Set.of()), + // Provisioning Entries, including the journal entries screen that belongs to it. + new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/provisioningentries(/.*)?"), Set.of()), + new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/journalentries/provisioning/?"), Set.of()), + // Accounting Rules. Only the Accounting Rules screens and Frequent Postings (disabled + // above) read these, so nothing still reachable depends on them. + new Rule("Accounting Rules", Set.of(), Pattern.compile("/v[1-9][0-9]*/accountingrules(/.*)?"), Set.of())); + + @Override + protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) + throws ServletException, IOException { + + final String pathInfo = request.getPathInfo(); + if (StringUtils.isBlank(pathInfo)) { + filterChain.doFilter(request, response); + return; + } + + final String method = request.getMethod(); + // Read "command" out of the query string rather than through getParameter(): the latter + // triggers servlet parameter parsing, which for a form-encoded or multipart POST consumes + // the request body before the resource ever sees it. This filter sits in front of every + // API call, so it must not touch the body of requests it has no interest in. + final String command = commandFromQueryString(request.getQueryString()); + for (final Rule rule : RULES) { + if (rule.matches(method, pathInfo, command)) { + reject(response, rule.feature()); + return; + } + } + filterChain.doFilter(request, response); + } + + private String commandFromQueryString(final String queryString) { + if (StringUtils.isBlank(queryString)) { + return null; + } + for (final String pair : queryString.split("&")) { + if (pair.startsWith("command=")) { + return URLDecoder.decode(pair.substring("command=".length()), StandardCharsets.UTF_8); + } + } + return null; + } + + private void reject(final HttpServletResponse response, final String feature) throws IOException { + response.setStatus(HttpStatus.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter() + .write("{\"developerMessage\":\"" + feature + " is disabled on this installation.\"," + + "\"httpStatusCode\":\"403\",\"defaultUserMessage\":\"" + feature + " is not available.\"," + + "\"userMessageGlobalisationCode\":\"error.msg.feature.disabled\"}"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 1006863bf..9a2281763 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -247,11 +248,54 @@ public class BranchDayRegisterApiResource { final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); + requireCashMigrationBeforeFirstEverOpen(officeId); + final Map res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations, openingDifferenceReason); return gson.toJson(res); } + /** + * A branch's very first business day open is also the moment its Cash at Vault GL account is + * supposed to start agreeing with what is physically in the till - that is exactly what Migrate + * Cash Denomination exists to do (count the drawer, define the GL from that count, open the day). + * This plain endpoint has no denomination-reconciliation step of its own: it just records whatever + * lump sum or count it is given as the day's opening cash, with no requirement that it line up + * with the ledger. Used for a branch's first-ever open, that would let day-to-day cash tracking + * begin while the GL silently sits at whatever it already was (typically zero on a brand-new + * tenant) - a mismatch nothing downstream would ever catch, since nothing here compares the two. + * + * Only the very first open is blocked: once Migrate Cash Denomination has posted anything to the + * cash GL for this office (regardless of the amount - even a correctly counted zero still means + * it went through the reconciliation step), every subsequent day-to-day open/close on this screen + * is untouched, exactly as it already works. + */ + private void requireCashMigrationBeforeFirstEverOpen(final Long officeId) { + final Integer priorDays = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ?", + Integer.class, officeId); + if (priorDays != null && priorDays > 0) { + return; + } + final Map gl; + try { + gl = resolveCashGlAccount(); + } catch (final PlatformDataIntegrityException e) { + // No Cash at Vault / Cash at Teller account configured for this tenant at all - nothing + // for this guard to reconcile against, so the plain open proceeds exactly as it always has. + return; + } + final Long glAccountId = ((Number) gl.get("id")).longValue(); + final Integer postedEntries = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND office_id = ? AND reversed = false", + Integer.class, glAccountId, officeId); + if (postedEntries == null || postedEntries == 0) { + throw new PlatformDataIntegrityException("error.msg.branchday.first.open.requires.cash.migration", + "This branch has never opened a business day before, and its " + gl.get("name") + + " GL account has no opening balance yet. Use Migrate Cash Denomination to count the " + + "vault and open the first business day together, instead of Open Day here."); + } + } + /** * The actual open-day work, factored out of {@link #openDay(String)} so * {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it @@ -482,6 +526,7 @@ public class BranchDayRegisterApiResource { private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final AppUser currentUser) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId); jdbcTemplate.update(""" INSERT INTO acc_gl_journal_entry (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index e5c162e48..b45a7fe52 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -1,6 +1,8 @@ package org.apache.fineract.portfolio.cashtransfer.api; import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import jakarta.ws.rs.Consumes; @@ -21,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -239,7 +242,15 @@ public class ShareAdvanceCollectionApiResource { // account to look in is never guessed from product configuration — the counter selects it // by hand every time, since a product mapping can silently go missing or point at the // wrong account and a plain voucher lookup should never depend on that being right. - final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); + Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); + if (adopted == null) { + // A member can also fund the same GL account through the Clerk's Disbursement Request + // register (a COLLECTION request debits the member's own savings account and credits a + // GL account, approved the same way a withdrawal is) — a second, equally legitimate way + // for money to reach Share Sundry / a fee-income account without a cashier counter + // deposit ever being raised. + adopted = adoptApprovedClerkDisbursementCollection(voucherNo, glAccountNoOverride); + } if (adopted != null) { return Response.ok(gson.toJson(adopted)).build(); } @@ -637,6 +648,81 @@ public class ShareAdvanceCollectionApiResource { receiptNo); } + /** + * Adopts an approved Clerk Disbursement Request ("COLLECTION" direction) into the Share Sundry GL as a share + * advance collection. + * + * A COLLECTION request debits the member's own savings account and credits a GL account, approved by a manager + * the same way a clerk withdrawal is -- a second, equally legitimate way for a member's money to reach Share + * Sundry (or a fee-income account) besides a cashier counter deposit. The request itself carries no client id + * column; the member is only recorded, as JSON, on the debit leg of {@code credit_entries}. + * + * @param glAccountNoOverride + * required -- same contract as {@link #adoptApprovedDeposit}: the GL account the counter selected, + * never guessed from product configuration. + * @return the newly created collection row, or null when no approved COLLECTION request matches. + */ + private Map adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) { + final String glAccountNo = glAccountNoOverride.trim(); + final List> requests = jdbcTemplate.queryForList( + "SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at" + + " FROM m_clerk_disbursement_request" + + " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'" + + " ORDER BY id DESC", + voucherNo, glAccountNo); + if (requests.isEmpty()) { + return null; + } + final Map req = requests.get(0); + + final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); + if (savingsAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client", + "Voucher " + voucherNo + " does not debit a member's savings account, so it cannot be matched to a member."); + } + final Long clientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + + final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(clientId); + final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR"; + final Object actionedAt = req.get("actioned_at"); + final Object submittedAt = req.get("submitted_at"); + final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp + ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + final String receiptNo = nextReceiptNo(); + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo, + toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo); + + return jdbcTemplate.queryForMap( + "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", + receiptNo); + } + + /** The savings account id named on the debit ("Dr") leg of a Clerk Disbursement Request's {@code credit_entries} + * JSON array, or null when that leg names a GL account instead (no member to match against). */ + private Long debitSavingsAccountId(final String creditEntriesJson) { + if (creditEntriesJson == null || creditEntriesJson.isBlank()) { + return null; + } + final JsonArray entries = JsonParser.parseString(creditEntriesJson).getAsJsonArray(); + for (final JsonElement el : entries) { + final JsonObject entry = el.getAsJsonObject(); + final boolean isDebit = entry.has("dbCr") && "Dr".equalsIgnoreCase(entry.get("dbCr").getAsString()); + if (isDebit && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + return entry.get("savingsAccountId").getAsLong(); + } + } + return null; + } + private Long loadClientOfficeId(final Long clientId) { return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId); } @@ -751,6 +837,7 @@ public class ShareAdvanceCollectionApiResource { private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + " entry_date, type_enum, amount, description, entity_id, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java index bca22f22d..1ee09f3a3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java @@ -21,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; @@ -360,6 +361,7 @@ public class ShareCollectionApiResource { final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index 65056173c..a1f2a0cfe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -49,6 +49,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -919,6 +920,7 @@ public class ClerkClosureRequestApiResource { private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, final AppUser currentUser) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId); jdbcTemplate.update(""" INSERT INTO acc_gl_journal_entry (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 969fe1a44..7e2a365fe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -49,6 +49,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -1061,6 +1062,7 @@ public class ClerkShareWithdrawalRequestApiResource { private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + " entry_date, type_enum, amount, description, entity_id, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index eb00771ee..1b5a4d8ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -45,6 +45,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -587,6 +588,8 @@ public class DividendClaimRequestApiResource { final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description, final AppUser currentUser) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId); + GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId); final String insertSql = """ INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 724686933..d5e81a559 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -378,6 +379,7 @@ public class SavingsPooledInterestPostingService { private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, final Long transactionId) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update(""" INSERT INTO acc_gl_journal_entry (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java index e576911c0..7a5c235c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -52,12 +52,21 @@ public final class DormancyClock { * Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a * WHERE clause without having to add a join it may already have — and so a client with no KYC * record at all falls back to the activation date rather than dropping out of the result. + * + *

+ * Null before the account is ever activated: {@code GREATEST} in Postgres ignores a null + * argument rather than propagating it, so without this guard an account still awaiting approval + * (activation date not yet set) would report the client's own KYC re-verification date as its + * "last live" date — a date that has nothing to do with this account, which has never been live + * at all. */ public static final String LAST_LIVE_ON = """ - GREATEST( - sa.activatedon_date, - COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k - WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), - sa.activatedon_date)) + CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE + GREATEST( + sa.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), + sa.activatedon_date)) + END """; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index fa87d7cfc..3282fd5a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -33,6 +33,7 @@ import jakarta.persistence.Transient; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; @@ -539,9 +540,13 @@ public class ShareAccount extends AbstractPersistableCustom { public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) { ShareAccountTransaction returnTrans = null; for (ShareAccountTransaction tran : this.shareAccountTransactions) { + // A charge/fee transaction carries no share count or unit price at all (both columns are + // null), so this account's own transaction list is not uniformly non-null the way a + // purchase-only list would be -- comparing those two fields has to tolerate that instead + // of assuming every transaction here is a share purchase. if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate()) - && tran.getTotalShares().equals(transaction.getTotalShares()) - && tran.getPurchasePrice().compareTo(transaction.getPurchasePrice()) == 0 + && Objects.equals(tran.getTotalShares(), transaction.getTotalShares()) + && nullSafeCompare(tran.getPurchasePrice(), transaction.getPurchasePrice()) && tran.getTransactionStatus().equals(transaction.getTransactionStatus()) && tran.getTransactionType().equals(transaction.getTransactionType())) { returnTrans = tran; @@ -551,6 +556,13 @@ public class ShareAccount extends AbstractPersistableCustom { return returnTrans; } + private boolean nullSafeCompare(final java.math.BigDecimal a, final java.math.BigDecimal b) { + if (a == null || b == null) { + return a == b; + } + return a.compareTo(b) == 0; + } + public LocalDate getSubmittedDate() { return this.submittedDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index d39cc5764..2986803f5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -27,6 +27,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -442,6 +443,7 @@ public class AdditionalShareTrancheFundingService { private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, final String description) { + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId); this.jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " + " entry_date, type_enum, amount, description, entity_id, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index b735ef91a..595e617df 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; @@ -156,6 +157,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe submitted_on_date, transaction_date) VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?) """; + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, payableDividendsAccountId); + GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, bankProfitAccountId); // Debit first, then credit, so the entry ids follow the direction the money travelled. this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index bbfa1822a..215f57def 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -29,6 +29,7 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandSource; @@ -660,6 +661,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount, final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId); + GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId); final AppUser currentUser = this.context.authenticatedUser(); jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..108e7421a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 02bfb8312..b3624aac4 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -74,4 +74,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml index c1c78f4cb..0fda9095b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0502_backfill_core_chart_of_accounts.xml @@ -60,11 +60,18 @@ + + 9:c6e3096bd3dd10d5ee586a699e85d74f + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy, description) SELECT 'Cash at Vault', '1', false, true, 1, 1, '.', 'Test' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '1'); - INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) - SELECT 'Cash at Tellers', '2', false, true, 1, 1, '.' WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '2'); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml index a28aee782..3632ec227 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -60,12 +60,13 @@ created. The latest day is kept: it is the one carrying the current cycle's running total. --> + + 9:e03c93d35b7af6e12cd6bff87b5b8020 @@ -78,8 +79,19 @@ + + 9:74afdaa4d7306bfa41f0a6939f653875 - + + + + + @@ -97,8 +109,14 @@ + + 9:9af730db6b99301bc2c87785afa42e78 - + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml new file mode 100644 index 000000000..f30f83d1a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_catchup_teller_is_deleted.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml new file mode 100644 index 000000000..2635d72d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_catchup_current_account_columns.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml new file mode 100644 index 000000000..38ac14bb7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_drop_unused_facility_charge_income_accounts.xml @@ -0,0 +1,49 @@ + + + + + + + DELETE FROM acc_gl_account a + WHERE a.gl_code IN ('400201','400202','400203','400204','400205','400206','400207','400208', + '400209','400210','400211','400212','400213','400214','400215','400216') + AND NOT EXISTS (SELECT 1 FROM acc_gl_journal_entry je WHERE je.account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_product_mapping pm WHERE pm.gl_account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_gl_financial_activity_account fa WHERE fa.gl_account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM m_charge c WHERE c.income_or_liability_account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_gl_account child WHERE child.parent_id = a.id) + + + + + + DELETE FROM acc_gl_account a + WHERE a.gl_code = '400200' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account child WHERE child.parent_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_gl_journal_entry je WHERE je.account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_product_mapping pm WHERE pm.gl_account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM acc_gl_financial_activity_account fa WHERE fa.gl_account_id = a.id) + AND NOT EXISTS (SELECT 1 FROM m_charge c WHERE c.income_or_liability_account_id = a.id) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml new file mode 100644 index 000000000..b203a843e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_group_orphan_gl_accounts_under_headers.xml @@ -0,0 +1,134 @@ + + + + + + + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Cash on Hand', '100100', false, true, 1, 2, '.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100100') + AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('100101','100102') AND parent_id IS NULL AND account_usage = 1) + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Sundry and Statutory Liabilities', '200900', false, true, 2, 2, '.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '200900') + AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('200901','200902','200903','200904','200905','200906') AND parent_id IS NULL AND account_usage = 1) + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Contra Accounts', '300800', false, true, 3, 2, '.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300800') + AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300000' AND parent_id IS NULL AND account_usage = 1) + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Reserves and Surplus', '300900', false, true, 3, 2, '.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '300900') + AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code IN ('300901','300902','300903') AND parent_id IS NULL AND account_usage = 1) + + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, classification_enum, account_usage, hierarchy) + SELECT 'Other Income', '400900', false, true, 4, 2, '.' + WHERE NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400900') + AND EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '400901' AND parent_id IS NULL AND account_usage = 1) + + + + + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.gl_code = '100100' + AND a.gl_code IN ('100101','100102') + AND a.parent_id IS NULL AND a.account_usage = 1 + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.gl_code = '200900' + AND a.gl_code IN ('200901','200902','200903','200904','200905','200906') + AND a.parent_id IS NULL AND a.account_usage = 1 + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.gl_code = '300800' + AND a.gl_code = '300000' + AND a.parent_id IS NULL AND a.account_usage = 1 + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.gl_code = '300900' + AND a.gl_code IN ('300901','300902','300903') + AND a.parent_id IS NULL AND a.account_usage = 1 + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.gl_code = '400900' + AND a.gl_code = '400901' + AND a.parent_id IS NULL AND a.account_usage = 1 + + + + + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.id = (SELECT id FROM acc_gl_account + WHERE gl_code IN ('500400','501000','500100') AND account_usage = 2 + ORDER BY CASE gl_code WHEN '500400' THEN 1 WHEN '501000' THEN 2 ELSE 3 END + LIMIT 1) + AND a.gl_code = '501200' + AND a.parent_id IS NULL AND a.account_usage = 1 + + + UPDATE acc_gl_account a + SET parent_id = h.id, hierarchy = COALESCE(h.hierarchy, '.') || a.id || '.' + FROM acc_gl_account h + WHERE h.id = (SELECT id FROM acc_gl_account + WHERE gl_code IN ('400100','400900') AND account_usage = 2 + ORDER BY CASE gl_code WHEN '400100' THEN 1 ELSE 2 END + LIMIT 1) + AND a.gl_code = '400103' + AND a.parent_id IS NULL AND a.account_usage = 1 + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml new file mode 100644 index 000000000..f921a8b68 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_uppercase_gl_account_names.xml @@ -0,0 +1,50 @@ + + + + + + + + UPDATE acc_gl_account SET name = 'SUNDRY CREDITORS A/C' WHERE name IN ('Share Sundry', 'Sundry'); + + + + + + + UPDATE acc_gl_account SET name = UPPER(name) WHERE name <> UPPER(name); + + + + From 6bb512e1fe2b920bf07673dd7988b78e7ec8004b Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 14 Sep 2026 07:19:05 +0530 Subject: [PATCH 44/55] fix(cash): recognize GL-entry cash correctly in reconciliation and reports - Add CountedCashLedger: one shared rule for which m_savings_txn_denomination rows are real counted cash. Replaces a magnitude-only guard (which had itself been silently excluding legitimate manual-GL-entry cash from every cash screen) with a date-plausibility test -- a stamped garbage id decodes back to the day it was written, a real GL reference to an arbitrary date -- so a hex reference that happens to land in the old guard's excluded band is no longer misclassified as timestamp garbage - Apply it consistently in the day register close preview, the cashier position/day-book reports, the cash reconciliation screen, and the withdrawal stock check, so all four agree on the same drawer - GL Entries report: join m_voucher_line to show both legs of each voucher (debit and credit account) instead of only the cash leg its own denomination row happens to carry Co-Authored-By: Claude Sonnet 5 --- .../api/BranchDayRegisterApiResource.java | 20 ++--- .../api/CashierDepositRequestApiResource.java | 6 +- .../api/CashReconciliationApiResource.java | 12 +-- .../cashreport/api/CashReportApiResource.java | 53 ++++++++--- .../service/CountedCashLedger.java | 90 +++++++++++++++++++ .../service/DenominationBreakdownWriter.java | 16 +--- 6 files changed, 148 insertions(+), 49 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 9a2281763..2bdc4ac2a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -43,6 +43,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; +import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -72,13 +73,6 @@ public class BranchDayRegisterApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); - // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id - // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a - // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is - // never corrected -- without this same filter here, the close-preview's counted-in/counted-out - // figures would include that phantom amount, on top of the real cash the branch actually counted. - private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; - // ── GET /v1/branchday ── the register itself ────────────────────────────── /** @@ -197,28 +191,32 @@ public class BranchDayRegisterApiResource { // cross-check was being compared against every branch's counted cash at once, which makes it // wrong at every office but the first. A row whose officer maps to no cashier is still // counted, for the same reason as the withdrawal legs in bookBreakdown. + // + // Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the + // note there on why a manual GL entry's decoded reference is kept and a stamped millisecond + // timestamp is not. final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' - AND (std.txn_id IS NULL OR std.txn_id < ?) + AND """ + CountedCashLedger.REAL_TXN_ID + """ AND (std.officer_id IS NULL OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id WHERE au.id = std.officer_id) OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id JOIN m_tellers t ON t.id = c.teller_id WHERE au.id = std.officer_id AND t.office_id = ?)) - """, BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); + """, BigDecimal.class, Date.valueOf(businessDate), officeId)); final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' - AND (std.txn_id IS NULL OR std.txn_id < ?) + AND """ + CountedCashLedger.REAL_TXN_ID + """ AND (std.officer_id IS NULL OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id WHERE au.id = std.officer_id) OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id JOIN m_tellers t ON t.id = c.teller_id WHERE au.id = std.officer_id AND t.office_id = ?)) - """, BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId)); + """, BigDecimal.class, Date.valueOf(businessDate), officeId)); result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index b2926c7ff..8060a8192 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -87,9 +87,9 @@ public class CashierDepositRequestApiResource { // DELETE) would silently rewrite or erase an unrelated real transaction's own denomination // breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no // real transaction id will reach in this system's lifetime makes every such match unambiguous, - // without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so - // a request still sitting PENDING continues to count in cashier-position reports -- the cash is - // physically in the drawer already, approval or not. + // without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id + // band (10^12) so a request still sitting PENDING continues to count in cashier-position reports + // -- the cash is physically in the drawer already, approval or not. private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L; private final JdbcTemplate jdbcTemplate; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index c303214ea..8f3cb2437 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -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.denominationmaster.service.CountedCashLedger; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -47,13 +48,6 @@ public class CashReconciliationApiResource { private final JdbcTemplate jdbcTemplate; private final Gson gson = new Gson(); - // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id - // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a - // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is - // never corrected -- without this same filter here, this endpoint (unlike the cashier-position - // report) would count that phantom figure into a cashier's expected cash. - private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; - /** * GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd * Returns denomination-wise expected balance for a cashier. @@ -96,7 +90,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'DEPOSIT' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + + "AND " + CountedCashLedger.REAL_TXN_ID + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " @@ -126,7 +120,7 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'WITHDRAWAL' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + + "AND " + CountedCashLedger.REAL_TXN_ID + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 7dc86fdf6..ebad591bb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; +import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -59,18 +60,14 @@ public class CashReportApiResource { * * *

- * The timestamp test is a magnitude test, not a lookup, because a missing transaction is not by - * itself wrong: the Demand Draft register keys its breakdowns by its own request id, and those - * rows are genuine cash that has to keep counting. Request ids and transaction ids are both - * small; a millisecond epoch is thirteen digits and cannot be either. So anything at or above - * 10^12 is rejected as not-an-id, and everything smaller is kept. + * Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared + * with the day register, the reconciliation screen and the withdrawal stock check, so a drawer + * reads the same on all four. */ - private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; - private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" + " SELECT 1 FROM m_savings_account_transaction rt " + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) " - + "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") " + + "AND " + CountedCashLedger.REAL_TXN_ID // A breakdown has to add up to the transaction it belongs to. Where it does not, the // notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven @@ -83,8 +80,9 @@ public class CashReportApiResource { // everything counted after it) out of the cashier's position. // // Compared per transaction inside a correlated subquery rather than as a join, so a - // breakdown with no transaction behind it -- a Demand Draft keyed by its request id -- - // is untouched by this test and still handled by the id-magnitude test above. + // breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a + // manual GL entry keyed by its decoded reference -- is untouched by this test and left to + // CountedCashLedger's rule above. + "AND NOT EXISTS (" + " SELECT 1 FROM m_savings_account_transaction mt " + " WHERE mt.id = std.txn_id" @@ -1341,21 +1339,42 @@ public class CashReportApiResource { final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr"); final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr"); - // 2. Paged data query - final String dataSql = "SELECT " + // 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL + // entry ever has a denomination breakdown for -- so on its own it names only the cash side + // (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the + // entry actually moved money between. Every voucher raised through VoucherService (both this + // one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line, + // the same table the My Work register reads its own debit/credit columns from -- joined here + // by voucher_no so this report shows the identical pair rather than only the half this + // table's own denomination row happens to know about. + final String baseSql = "SELECT " + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), " + " CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " std.account_no AS ac_no, " + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " MAX(std.txn_date) AS txn_date, " + " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, " + + " MAX(std.created_at) AS sort_key, " + " MAX(std.officer_username) AS teller_name, " + " MAX(std.payment_type) AS payment_type, " + " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, " + " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, " + " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount " - + baseGroupSql - + "ORDER BY MAX(std.created_at) " + + baseGroupSql; + + final String dataSql = "SELECT b.*, " + + " dr.party_name AS debit_party, dr.account_no AS debit_account, " + + " cr.party_name AS credit_party, cr.account_no AS credit_account " + + "FROM (" + baseSql + ") b " + + "LEFT JOIN LATERAL (" + + " SELECT party_name, account_no FROM m_voucher_line" + + " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'" + + " ORDER BY line_no LIMIT 1) dr ON TRUE " + + "LEFT JOIN LATERAL (" + + " SELECT party_name, account_no FROM m_voucher_line" + + " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'" + + " ORDER BY line_no LIMIT 1) cr ON TRUE " + + "ORDER BY b.sort_key " + "LIMIT ? OFFSET ?"; // Append limit and offset to params @@ -1376,6 +1395,12 @@ public class CashReportApiResource { row.put("txnSource", rs.getString("txn_source")); row.put("dr", rs.getBigDecimal("dr_amount")); row.put("cr", rs.getBigDecimal("cr_amount")); + // Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher + // was raised before m_voucher_line existed, or belongs to no register that writes one. + row.put("debitParty", rs.getString("debit_party")); + row.put("debitAccount", rs.getString("debit_account")); + row.put("creditParty", rs.getString("credit_party")); + row.put("creditAccount", rs.getString("credit_account")); return row; }, pagedParams); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java new file mode 100644 index 000000000..3fa7bcd11 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -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.portfolio.denominationmaster.service; + +/** + * The one definition of which {@code m_savings_txn_denomination} rows are real counted cash. + * + *

+ * Written here once because it is read from four places -- the branch day register's close preview, + * the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and + * those places disagreeing is what makes a drawer hold one figure on one screen and another on the + * next. A change to the rule belongs in this file and nowhere else. + * + *

What the rule has to separate

+ * + *

+ * The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes + * a different kind of id into it: + * + *

    + *
  • a savings, current or deposit transaction writes its own transaction id -- always small; + *
  • a cashier deposit request writes its request id plus a fixed offset -- also small; + *
  • a Demand Draft issue writes its own request id -- small; + *
  • a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into + * this column (see {@code CashierGlDenominationApiResource}, matching the encoding + * {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very + * large, but a hex string beginning with low digits can land anywhere; + *
  • and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to + * no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are + * carried forward for ever, and one database held ₹7.29 lakh of them. + *
+ * + *

Why this is not a magnitude test

+ * + *

+ * A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject + * anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows. + * A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661, + * which sits squarely inside the 13-digit band a millisecond epoch occupies. + * + *

+ * What actually separates them is that a millisecond epoch stamped at write time decodes back to + * approximately the day the row itself belongs to, while a hex reference that happens to land in the + * same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026 + * transaction date. So the test reads the id as a date and asks whether it lands on the row's own + * day. That is self-maintaining: it needs no constant moved forward as real timestamps grow. + */ +public final class CountedCashLedger { + + private CountedCashLedger() {} + + /** Widest gap still read as "this id is the moment the row was written", in days. */ + private static final int SAME_DAY_TOLERANCE_DAYS = 7; + + /** + * SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over + * the alias {@code std} ({@code m_savings_txn_denomination}). + * + *

+ * The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside + * PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value + * this column can hold can overflow the comparison. + * + *

+ * Padded with a space at each end so it reads the same spliced into a text block -- which strips + * the trailing space off the {@code AND } it follows -- as concatenated onto a plain string. + */ + public static final String REAL_TXN_ID = " (std.txn_id IS NULL" + + " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000" + + " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL" + + " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN" + + " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS + + " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") "; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 8e4c9b5fb..3d22b4fdf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -40,14 +40,6 @@ public class DenominationBreakdownWriter { private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; - // Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id - // is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a - // millisecond timestamp, decoded back out of hex) that never links to a real transaction and is - // never corrected -- without this same filter here, a cashier's computed stock (and the - // insufficient-stock check that guards a withdrawal against it) would count that phantom figure - // as real notes and coins in the drawer. - private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L; - // ── Public API ──────────────────────────────────────────────────────────── /** @@ -294,7 +286,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + + " AND " + CountedCashLedger.REAL_TXN_ID + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -318,7 +310,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + + " AND " + CountedCashLedger.REAL_TXN_ID + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -415,7 +407,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + + " AND " + CountedCashLedger.REAL_TXN_ID + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" @@ -439,7 +431,7 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ")" + + " AND " + CountedCashLedger.REAL_TXN_ID + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + " GROUP BY std.denomination_id" From 1c6ac9d77fb13286e98c043bea5927eb062d2f42 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 14 Sep 2026 11:46:16 +0530 Subject: [PATCH 45/55] feat: AML direction/entity scoping, Sponsor DD revoke workflow, Dividend GL resolution & TDS module --- ...FinancialActivityAccountDataValidator.java | 29 +- .../common/AccountingConstants.java | 27 +- .../aml/domain/AmlTransactionRule.java | 43 +++ .../common/DividendGlAccountResolver.java | 112 +++++++ .../api/DistributableProfitApiResource.java | 14 +- .../api/IncomeProfitTransferApiResource.java | 2 +- .../service/IncomeProfitTransferService.java | 47 ++- .../aml/api/AmlCtrReportApiResource.java | 14 + .../aml/data/AmlCtrReportLineData.java | 50 +++ .../portfolio/aml/data/AmlDirection.java | 45 +++ .../portfolio/aml/data/AmlSubjectScope.java | 64 ++++ .../aml/data/AmlTransactionContext.java | 56 ++++ .../aml/event/AmlDomainServiceImpl.java | 29 +- .../AmlCtrReportReadPlatformService.java | 9 + .../AmlCtrReportReadPlatformServiceImpl.java | 59 ++++ .../service/AmlRuleEvaluationServiceImpl.java | 120 +++++++- .../aml/service/AmlSubjectScopeResolver.java | 136 +++++++++ .../api/DividendClaimRequestApiResource.java | 42 +-- .../ShareAccountSchedularServiceImpl.java | 19 +- .../start/ShareAccountsConfiguration.java | 5 +- .../api/AllProductsDividendApiResource.java | 22 +- ...WritePlatformServiceJpaRepositoryImpl.java | 132 +++----- .../starter/ShareProductsConfiguration.java | 7 +- .../sponsordd/api/SponsorDDApiResource.java | 46 +++ .../sponsordd/data/SponsorDDLeafletData.java | 16 + .../data/SponsorDDRevokeRequestData.java | 66 ++++ .../service/SponsorDDReadPlatformService.java | 4 + .../SponsorDDReadPlatformServiceImpl.java | 98 +++++- ...orDDRevokeRequestWritePlatformService.java | 50 +++ ...RevokeRequestWritePlatformServiceImpl.java | 288 ++++++++++++++++++ .../tds/api/TdsInterestApiResource.java | 70 +++++ .../portfolio/tds/data/TdsDeducteeData.java | 54 ++++ .../TdsInterestReadPlatformService.java | 34 +++ .../TdsInterestReadPlatformServiceImpl.java | 206 +++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 3 + .../tenant/final-changelog-tenant.xml | 3 + .../0539_map_profit_financial_activities.xml | 66 ++++ ..._dividend_round_off_financial_activity.xml | 65 ++++ ...ure_profit_financial_activities_mapped.xml | 120 ++++++++ ...0546_aml_rule_direction_and_cash_scope.xml | 76 +++++ .../parts/0547_aml_rule_subject_scope.xml | 47 +++ .../parts/0548_sponsor_dd_revoke_request.xml | 109 +++++++ 42 files changed, 2319 insertions(+), 185 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java index e69cddba7..b978047ff 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/financialactivityaccount/serialization/FinancialActivityAccountDataValidator.java @@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator { */ private final Set supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues(); + /** + * Activities an administrator may map from the Financial Activity Mappings screen. + * + * Held in one place because create and update both validate against it, and the two lists had already drifted - + * update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even + * though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be + * seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable. + */ + private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(), + FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(), + FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), + FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(), + FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(), + FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(), + FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() }; + private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue(); private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue(); @@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator { final JsonElement element = this.fromApiJsonHelper.parse(json); final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element); - baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues( - FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(), - FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(), - FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(), - FinancialActivity.PAYABLE_DIVIDENDS.getValue()); + baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull() + .isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES); final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element); baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero(); @@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator { if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) { final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element); - baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues( - FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(), - FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(), - FinancialActivity.PAYABLE_DIVIDENDS.getValue()); + baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull() + .isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES); } if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) { diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index 655527918..a5a4ead03 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -451,7 +451,32 @@ public final class AccountingConstants { PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), // DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), // RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), // - DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); // + DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), // + /** + * The year's closed-out profit a dividend is declared against. + * + * Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that + * has already been through a distribution decision must not sit here, or the same money would be offered for + * distribution a second time. + */ + DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), // + /** + * Where profit settles once it is no longer distributable - the remainder left after an approved declaration + * and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend + * calculation. + */ + RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), // + /** + * Where the paise a dividend cannot divide evenly are collected. + * + * A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be + * swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside + * a figure the society reads as a real business result. Given its own account it stays separately readable - + * and an auditor asking why members received less than was declared can be answered from the ledger. + * + * Optional: a society that has not mapped it has the residue swept to retained profit exactly as before. + */ + DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); // private final Integer value; private final String code; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java index acf7ffb7a..aa11a2ba9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java @@ -84,6 +84,49 @@ public class AmlTransactionRule { @Column(name = "is_active", nullable = false) private boolean active; + /** + * CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies. + * A rule named for withdrawals must not fire on a deposit. + */ + @Column(name = "direction", length = 10) + private String direction; + + /** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */ + @Column(name = "cash_only", nullable = false) + private boolean cashOnly; + + /** + * How many transactions must make up the window before a pattern rule fires. Structuring is + * many small transactions; without this floor a single large one satisfies the window total and + * reads as a pattern it is not. + */ + @Column(name = "min_transaction_count") + private Integer minTransactionCount; + + /** + * Which customers a rule is about, where the rule is about the customer rather than the + * transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is + * about the transaction alone and so applies to everyone. + */ + @Column(name = "subject_scope", length = 30) + private String subjectScope; + + public String getSubjectScope() { + return subjectScope; + } + + public String getDirection() { + return direction; + } + + public boolean isCashOnly() { + return cashOnly; + } + + public Integer getMinTransactionCount() { + return minTransactionCount; + } + protected AmlTransactionRule() {} /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java new file mode 100644 index 000000000..67f81c924 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/common/DividendGlAccountResolver.java @@ -0,0 +1,112 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.common; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator + * configures. + * + *

+ * These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread + * across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend + * journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from + * the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one + * screen. + * + *

+ * Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made + * the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws + * rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to + * configure, instead of leaving a declaration that appears to have worked but moved nothing. + */ +@Service +@RequiredArgsConstructor +public class DividendGlAccountResolver { + + private final JdbcTemplate jdbcTemplate; + + /** The year's closed-out profit a dividend is declared against. */ + public Long distributableProfitAccount() { + return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT); + } + + /** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */ + public Long retainedProfitAccount() { + return requireMapped(FinancialActivity.RETAINED_PROFIT); + } + + /** Equity the declared profit is set aside in, between declaration and board approval. */ + public Long reserveForDividendAccount() { + return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND); + } + + /** The liability to members, created on approval and discharged as each member claims. */ + public Long payableDividendsAccount() { + return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS); + } + + /** + * Where the paise a dividend cannot divide evenly are swept. + * + * Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed + * - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets + * the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than + * a decision the society has failed to make, which is why it resolves rather than throwing. + */ + public Long dividendRoundOffAccount() { + final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF); + return roundOff != null ? roundOff : retainedProfitAccount(); + } + + /** + * Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure. + * + * Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend + * Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it + * returns null instead of throwing. + */ + public Long dividendPaidAccountOrNull() { + return mappedAccount(FinancialActivity.DIVIDEND_PAID); + } + + /** The GL mapped to an activity, or null when the society has not configured it. */ + public Long mappedAccount(final FinancialActivity activity) { + return this.jdbcTemplate + .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, + activity.getValue()) + .stream().findFirst().orElse(null); + } + + private Long requireMapped(final FinancialActivity activity) { + final Long glAccountId = mappedAccount(activity); + if (glAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured", + "No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity " + + "Mappings before running the dividend cycle.", + activity.getValue()); + } + return glAccountId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java index afb530e25..db12703e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/profitandloss/api/DistributableProfitApiResource.java @@ -37,6 +37,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -60,13 +61,12 @@ import org.springframework.stereotype.Component; public class DistributableProfitApiResource { /** - * The year's closed-out profit, which is what a dividend may be declared against; kept in step with - * IncomeProfitTransferService.PROFIT_GL_CODE. + * The year's closed-out profit, which is what a dividend may be declared against. * - * Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would + * Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would * offer profit already distributed once for distribution again. */ - private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + private final DividendGlAccountResolver dividendGlAccounts; private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage"; @@ -162,9 +162,9 @@ public class DistributableProfitApiResource { final BigDecimal balance = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) FROM acc_gl_journal_entry je - JOIN acc_gl_account a ON a.id = je.account_id - WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? - """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1])); + WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]), + java.sql.Date.valueOf(range[1])); return balance == null ? BigDecimal.ZERO : balance; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java index abae1f99f..2e0860e97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/api/IncomeProfitTransferApiResource.java @@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource { ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE : incomeGlCodeParam.trim(); final Map incomeGl = service.loadGlByCode(incomeGlCode); - final Map profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE); + final Map profitGl = service.loadDistributableProfitGl(); final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear() : financialYearParam.trim(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 464ddee50..42b55ec4b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.dao.EmptyResultDataAccessException; @@ -22,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional; * *

  *   Dr  Share Entry Fee Income (10.4)   = balance for the year
- *   Cr  Bank Profit (BANKPROFIT01)
+ *   Cr  Distributable Profit            (the GL mapped to that financial activity)
  * 
* * Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as @@ -42,22 +43,23 @@ public class IncomeProfitTransferService { private static final int DEBIT = 2; private static final int CREDIT = 1; - /** Equity GL that accumulates profit. */ - /** - * The year's profit is closed into the Non-Distributable account, not plain Bank Profit. - * - * A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once - * the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years' - * leftovers, and the next declaration would be sized on profit that had already been through a distribution - * decision — offering the same money for distribution twice. - */ - public static final String PROFIT_GL_CODE = "BANKPROFITND01"; - /** Income GL closed to profit by the year-end job — the share entry fee. */ public static final String DEFAULT_INCOME_GL_CODE = "10.4"; private final JdbcTemplate jdbcTemplate; + /** + * The year's profit is closed into the Distributable Profit account, not the Retained one. + * + * A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit + * once the declaration is approved. Closing into Retained directly would mix the year just ended with earlier + * years' leftovers, and the next declaration would be sized on profit that had already been through a distribution + * decision — offering the same money for distribution twice. + * + * Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01. + */ + private final DividendGlAccountResolver dividendGlAccounts; + /** * Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when * there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not @@ -72,7 +74,7 @@ public class IncomeProfitTransferService { final String note, final boolean strict) { final Map incomeGl = loadGlByCode(incomeGlCode); - final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map profitGl = loadDistributableProfitGl(); final Long incomeGlId = toLong(incomeGl.get("id")); final Long profitGlId = toLong(profitGl.get("id")); @@ -165,7 +167,7 @@ public class IncomeProfitTransferService { public Map closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note, final boolean strict) { - final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map profitGl = loadDistributableProfitGl(); final Long profitGlId = toLong(profitGl.get("id")); final LocalDate[] range = financialYearRange(financialYear); final Long officeId = headOfficeId(); @@ -283,7 +285,7 @@ public class IncomeProfitTransferService { */ public BigDecimal profitForFinancialYear(final String financialYear) { final LocalDate[] range = financialYearRange(financialYear); - final Map profitGl = loadGlByCode(PROFIT_GL_CODE); + final Map profitGl = loadDistributableProfitGl(); final BigDecimal balance = jdbcTemplate.queryForObject( "SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) " + " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?", @@ -342,6 +344,21 @@ public class IncomeProfitTransferService { } } + /** As {@link #loadGlByCode}, for an account already identified by a financial activity mapping. */ + public Map loadGlById(final Long glAccountId) { + try { + return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId); + } catch (EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", + "GL account " + glAccountId + " does not exist."); + } + } + + /** The distributable-profit GL the year's income closes into, as configured on Financial Activity Mappings. */ + public Map loadDistributableProfitGl() { + return loadGlById(this.dividendGlAccounts.distributableProfitAccount()); + } + public Long headOfficeId() { return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java index e812d95d6..08289700e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java @@ -25,6 +25,7 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import java.time.YearMonth; import java.util.List; @@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; +import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData; import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService; import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService; import org.springframework.stereotype.Component; @@ -47,6 +49,7 @@ public class AmlCtrReportApiResource { private final AmlCtrReportReadPlatformService readPlatformService; private final AmlCtrReportGenerationService generationService; private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final DefaultToApiJsonSerializer lineJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -58,6 +61,17 @@ public class AmlCtrReportApiResource { return this.toApiJsonSerializer.serialize(reports); } + @GET + @Path("lines") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "The reportable lines behind the generated CTRs", + description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.") + public String retrieveLines(@QueryParam("reportId") final Long reportId) { + this.context.authenticatedUser(); + return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId)); + } + @POST @Path("generate") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java new file mode 100644 index 000000000..2822c074c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlCtrReportLineData.java @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather + * than only the ids the line is stored under. + * + *

+ * The stored line keeps a client id and a transaction id; a filing needs the account, the customer, + * the branch and the date those ids stand for. They are joined here so the screen and the CSV are + * built from the report the bank generated, not from a second calculation that could disagree with + * it. + * + *

+ * A connected-group line has no single transaction behind it - it is a month's worth of smaller + * cash movements by one customer that together cross the threshold - so its transaction id, account + * and date are null, and {@code connectedGroup} says why. + */ +public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId, + Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction, + BigDecimal amount, boolean connectedGroup, + /** + * Account facts the Account_Detail file reports. They were being filled in with a constant + * opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement + * about the customer's account that nobody had checked. + */ + LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus, + /** Customer identifiers the Account_Person_Relation file reports. */ + String ckycNumber, String clientExternalId, boolean individual) implements Serializable {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java new file mode 100644 index 000000000..41a063311 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlDirection.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.data; + +/** + * Which way the money moved, for rules written about one direction only. + * + *

+ * Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised + * "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one + * transaction were of that kind, and an analyst reading them cannot tell which one is real. + */ +public enum AmlDirection { + + /** Money into the customer's account: a deposit, or a loan disbursal. */ + CREDIT, + /** Money out of it: a withdrawal, or a loan repayment. */ + DEBIT, + /** Not established — the rule's own direction filter, if it has one, then does not apply. */ + UNKNOWN; + + public boolean isCredit() { + return this == CREDIT; + } + + public boolean isDebit() { + return this == DEBIT; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java new file mode 100644 index 000000000..c8ff16e98 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlSubjectScope.java @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.data; + +/** + * The kind of customer a rule is written about, for rules whose subject is the customer rather than + * the transaction. + * + *

+ * Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity + * in their own title, but nothing checked it: each was matched on amount alone and so fired on every + * ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious + * reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts, + * and the one real politically exposed person is then missed among them. + */ +public enum AmlSubjectScope { + + /** Politically exposed person, their relative or close associate. */ + PEP, + + /** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */ + CHARITABLE_ORG, + + /** + * Dealers in precious metal and stones and the other businesses RBI names as higher risk, + * where a large transaction is worth reporting that would be routine for anyone else. + */ + HIGH_RISK_BUSINESS, + + /** + * A recently opened account. A large transaction through an account that has barely been used + * is the classic mule pattern; the same transaction on a decade-old account is not remarkable. + */ + NEW_ACCOUNT; + + public static AmlSubjectScope fromRule(final String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return AmlSubjectScope.valueOf(value.trim().toUpperCase()); + } catch (final IllegalArgumentException e) { + // An unrecognised scope must not silently widen the rule back to everyone, so the + // caller treats null-from-blank and this case differently. + return null; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java index 4f7b66fa9..7a700e99b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlTransactionContext.java @@ -19,7 +19,9 @@ package org.apache.fineract.portfolio.aml.data; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; import org.apache.fineract.portfolio.client.domain.Client; @@ -45,9 +47,43 @@ public final class AmlTransactionContext { * rules are simply skipped for this context rather than guessed at. */ private final BigDecimal windowAggregateAmount; + /** + * Which way the money moved. A rule written about withdrawals must not fire on a deposit, and + * "repayment of loan in cash" is not the same event as a disbursal. + */ + private final AmlDirection direction; + /** + * How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a + * pattern of MANY small transactions, so the count is what separates it from one large one: + * a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads. + */ + private final int windowTransactionCount; + /** + * Every qualifying amount in the window, so a rule can count only what falls inside its own + * band. A single count cannot serve every rule: this client's window held four deposits — three + * small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule + * whose band none of the four sat in. Structuring is several transactions of a similar size, + * and only the amounts themselves can say whether that happened. + */ + private final List windowAmounts; + + /** + * When the account this transaction moved through was opened. The "large transaction on a new + * account" rule is about the account rather than the customer, and an old customer can open a + * new account. Null where the caller could not establish it, in which case the customer's own + * activation date stands in. + */ + private final LocalDate subjectOpenedOn; public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount, String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) { + this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN, + 0, List.of(), null); + } + + public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount, + String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction, + int windowTransactionCount, List windowAmounts, LocalDate subjectOpenedOn) { this.transactionType = transactionType; this.transactionId = transactionId; this.client = client; @@ -56,6 +92,26 @@ public final class AmlTransactionContext { this.occurredOn = occurredOn; this.cash = cash; this.windowAggregateAmount = windowAggregateAmount; + this.direction = direction; + this.windowTransactionCount = windowTransactionCount; + this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts); + this.subjectOpenedOn = subjectOpenedOn; + } + + public AmlDirection getDirection() { + return direction; + } + + public int getWindowTransactionCount() { + return windowTransactionCount; + } + + public List getWindowAmounts() { + return windowAmounts; + } + + public LocalDate getSubjectOpenedOn() { + return subjectOpenedOn; } public AmlTransactionType getTransactionType() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java index 17698fcbe..1a575b614 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/event/AmlDomainServiceImpl.java @@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent; import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.aml.data.AmlDirection; import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository; @@ -113,12 +114,22 @@ public class AmlDomainServiceImpl implements AmlDomainService { } final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS); final List recent = savingsQueryRepository.findForClientSince(client.getId(), since); - final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()) - .map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + // Same side of the ledger as the transaction being observed: a deposit is not part of a + // withdrawal pattern, so the two are aggregated separately. + final List sameDirection = recent.stream() + .filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList(); + // Each amount is carried through, not just the total: a rule has to be able to count what + // falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or + // more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be + // reported as structuring. + final List windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList(); + final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add); final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client, transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(), - transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate); + transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, + depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts, + transaction.getSavingsAccount().getActivationDate()); try { ruleEvaluationService.evaluate(context); } catch (final RuntimeException e) { @@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService { } final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS); final List recent = loanQueryRepository.findForClientSince(client.getId(), since); - final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType) - .map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add); + final List repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList(); + final List windowAmounts = repayments.stream() + .map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList(); + final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + // A repayment takes money from the customer; a disbursal gives it to them. Rules such as + // "repayment of loan in cash" are about the first and must not fire on the second. + final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT; final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client, transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(), - transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate); + transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction, + repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate()); try { ruleEvaluationService.evaluate(context); } catch (final RuntimeException e) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java index 0a6e9ddd4..6a1dfabcf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java @@ -20,8 +20,17 @@ package org.apache.fineract.portfolio.aml.service; import java.util.List; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; +import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData; public interface AmlCtrReportReadPlatformService { List retrieveAll(); + + /** + * The reportable lines, resolved to the account, customer and branch a filing names. + * + * @param reportId + * one month's report, or null for every generated month + */ + List retrieveLines(Long reportId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java index 194c237d1..ecd0dc29e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java @@ -18,13 +18,16 @@ */ package org.apache.fineract.portfolio.aml.service; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.aml.data.AmlCtrReportData; +import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,7 +36,40 @@ import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService { + /** + * Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash + * moved, so the line says so rather than leaving the reader to infer it from the amount. + */ + private static final String LINES_SQL = """ + SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id, + l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group, + sa.account_no AS account_no, o.name AS branch_name, t.transaction_date, + CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit' + WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction, + sa.activatedon_date AS account_opened_on, + sa.closedon_date AS account_closed_on, + CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED' + WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED' + WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED' + WHEN 800 THEN 'MATURED' END AS account_status, + k.ckyc_number AS ckyc_number, + -- The customer's own reference for the filing: CKYC number if the record carries + -- one, else the CIF the bank knows them by. Never blank, because a relation row + -- that identifies nobody cannot be reconciled against the customer card. + COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id, + COALESCE(c.legal_form_enum, 1) = 1 AS individual + FROM m_aml_ctr_report_line l + JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id + JOIN m_client c ON c.id = l.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_kyc k ON k.client_id = c.id + LEFT JOIN m_savings_account_transaction t + ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS' + LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id + """; + private final AmlCtrReportRepository repository; + private final JdbcTemplate jdbcTemplate; @Override public List retrieveAll() { @@ -41,6 +77,29 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat .map(this::mapToData).collect(Collectors.toList()); } + @Override + public List retrieveLines(final Long reportId) { + final StringBuilder sql = new StringBuilder(LINES_SQL); + final List args = new ArrayList<>(); + if (reportId != null) { + sql.append(" WHERE l.ctr_report_id = ? "); + args.add(reportId); + } + // Largest first: the line most likely to be questioned is the one the reviewer reads first. + sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id "); + + return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"), + rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"), + rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"), + rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"), + rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), + rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"), + rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(), + rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(), + rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"), + rs.getBoolean("individual")), args.toArray()); + } + private AmlCtrReportData mapToData(AmlCtrReport report) { return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(), report.getFileReference(), report.getStatus().name(), report.getSubmittedOn()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 843a436d5..2c8023e39 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.aml.data.AmlDirection; import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; @@ -54,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { private final AmlAlertRepository alertRepository; private final ClientKycRepository clientKycRepository; private final AmlRecurringAlertNotificationService recurringAlertNotificationService; + private final AmlSubjectScopeResolver subjectScopeResolver; @Override public List evaluate(AmlTransactionContext context) { @@ -81,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { } private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) { + if (!appliesToThisTransaction(rule, context)) { + return false; + } final BigDecimal threshold = resolveThreshold(rule, context); if (threshold == null) { // Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic @@ -91,14 +96,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { case THRESHOLD: return context.getAmount().compareTo(threshold) >= 0; case STRUCTURING: + return isStructuringHit(rule, context, threshold); case VELOCITY: - return context.getWindowAggregateAmount() != null - && context.getWindowAggregateAmount().compareTo(threshold) >= 0; + return isVelocityHit(rule, context, threshold); default: return false; } } + /** + * Whether this rule is about this transaction at all, before any amount is compared. + * + *

+ * Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000 + * cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low + * cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about + * transactions this was not. An analyst who cannot tell which of ten alerts is real stops + * reading them, and that is how a real one gets missed. + */ + private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) { + // A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by + // definition and is not what CTR exists to catch. + if (rule.isCashOnly() && !context.isCash()) { + return false; + } + // Direction, where the rule names one. UNKNOWN means the caller could not establish it, so + // the filter is not applied rather than guessed at. + final String ruleDirection = rule.getDirection(); + if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN + && !ruleDirection.equalsIgnoreCase(context.getDirection().name())) { + return false; + } + // And who the rule is about, for rules named after an entity rather than an amount. + // PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing + // here asked whether that customer was politically exposed. + return subjectScopeResolver.matches(rule.getSubjectScope(), context); + } + + /** + * Structuring: many transactions each deliberately kept below a reporting threshold, summing + * to something that would have been reportable had it been done at once. + * + *

+ * Two things separate it from one large transaction, and neither was being checked. The count: + * a pattern needs several transactions, so a single deposit is never structuring however large. + * And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000 + * deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what + * that rule used to report it as. + */ + private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) { + if (context.getWindowAggregateAmount() == null) { + return false; + } + if (!isWithinRuleBand(rule, context.getAmount())) { + return false; + } + // Counted and summed over the rule's own band, not over the window as a whole. A window + // holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a + // ₹10,00,000 total on the raw figures, and that is how one large deposit came to be + // reported as structuring: none of the four was in the band the rule describes. + final List inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList(); + if (!hasEnoughTransactions(rule, inBand.size())) { + return false; + } + return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0; + } + + /** + * Velocity: an unusual number of transactions in a short window, whatever each one is worth. + * + *

+ * Like structuring it needs a count — one transaction is not a velocity — but unlike structuring + * the individual amounts are not confined to a band, since the concern is the frequency itself. + */ + private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) { + if (context.getWindowAggregateAmount() == null) { + return false; + } + // Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run + // of small ones, and a single ₹12,00,000 is not an instance of it however often the account + // is used. + if (!isWithinRuleBand(rule, context.getAmount())) { + return false; + } + final List inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList(); + if (!hasEnoughTransactions(rule, inBand.size())) { + return false; + } + return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0; + } + + /** + * Whether this transaction sits inside the band the rule describes. + * + *

+ * A rule with no band stated matches any amount, which is how these rules behaved before the + * columns existed and is what a rule that only names a window total still means. + */ + private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) { + final BigDecimal min = rule.getMinLimitAmount(); + final BigDecimal max = rule.getMaxLimitAmount(); + if (min != null && amount.compareTo(min) < 0) { + return false; + } + return max == null || amount.compareTo(max) <= 0; + } + + /** + * Whether enough transactions have happened in the window to call this a pattern. + * + *

+ * A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3, + * because two large deposits in a month is ordinary business and RBI's own wording is + * "numerous". + */ + private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) { + final Integer minimum = rule.getMinTransactionCount(); + return minimum == null || minimum <= 1 || countInBand >= minimum; + } + /** * Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged * from prior behaviour. Tiered rules look up the transacting client's current KYC risk category diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java new file mode 100644 index 000000000..cea777c2f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlSubjectScopeResolver.java @@ -0,0 +1,136 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.service; + +import java.time.LocalDate; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.aml.data.AmlSubjectScope; +import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepository; +import org.springframework.stereotype.Service; + +/** + * Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is + * written about. + * + *

+ * Everything here is read from what the customer record already holds - the client type chosen at + * onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount, + * because the amount is what the rule's threshold is already for. + */ +@Service +@RequiredArgsConstructor +public class AmlSubjectScopeResolver { + + /** + * Client types that make an account a charitable/not-for-profit one for the purposes of the + * cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are + * per-tenant and these are seeded per tenant. + */ + private static final Set CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST"); + + /** + * Businesses RBI names as carrying higher money-laundering risk, where a large transaction is + * worth a second look that the same amount elsewhere would not be. + */ + private static final Set HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT", + "DEALER IN PRECIOUS METALS", "CASINO"); + + /** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */ + private static final int NEW_ACCOUNT_DAYS = 180; + + private static final int HIGH_RISK_KYC_CATEGORY = 3; + + private final ClientKycRepository clientKycRepository; + + /** + * Whether a rule carrying this scope should look at this transaction at all. + * + *

+ * A rule with no scope is about the transaction rather than the customer and applies to + * everyone, which is how these rules behaved before the column existed. + */ + public boolean matches(final String configuredScope, final AmlTransactionContext context) { + final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope); + if (scope == null) { + return true; + } + final Client client = context.getClient(); + if (client == null) { + return false; + } + return switch (scope) { + case PEP -> isPoliticallyExposed(client); + case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES); + case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES); + case NEW_ACCOUNT -> isNewAccount(context); + }; + } + + /** + * There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that + * fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is + * what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not + * treated as a PEP - a rule that cannot identify its subject must raise nothing rather than + * report everyone. + */ + private boolean isPoliticallyExposed(final Client client) { + if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP") + || codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) { + return true; + } + return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue) + .filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent(); + } + + private boolean isClientTypeIn(final Client client, final Set wanted) { + final String type = client.clientType() == null ? null : client.clientType().getLabel(); + final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel(); + return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification); + } + + /** + * Measured from the customer's activation date against the business date, not the wall clock, + * so a back-dated test and a back-dated correction both read the age the books read. + */ + private boolean isNewAccount(final AmlTransactionContext context) { + final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn() + : context.getClient().getActivationDate(); + if (openedOn == null) { + return false; + } + return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate()); + } + + private boolean containsIgnoreCase(final Set wanted, final String value) { + if (value == null) { + return false; + } + final String normalised = value.trim().toUpperCase(); + return wanted.contains(normalised); + } + + private boolean codeValueMatches(final String value, final String token) { + return value != null && value.trim().toUpperCase().contains(token); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index eb00771ee..2c675b96e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -45,6 +45,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -94,12 +95,11 @@ public class DividendClaimRequestApiResource { private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); - private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; + /** The dividend GL accounts, as configured on Accounting > Financial Activity Mappings. */ + private final DividendGlAccountResolver dividendGlAccounts; /** A dividend payout, keyed by its m_share_dividend_claim_request id. */ private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST"; - /** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */ - private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; @@ -492,23 +492,31 @@ public class DividendClaimRequestApiResource { * Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration * and forfeiture entries take, so a missing GL mapping never traps a member's dividend. */ + /** + * The DD Payable liability a dividend draft is raised against. + * + * m_sponsor_dd_gl_mapping holds a single global row — it was keyed by payment type until 0349 dropped that column, + * and the read side reports it as "Global" — so there is one mapping for every sponsor bank, and the first row is + * the only row. Kept as its own method so this reads as a deliberate choice rather than an arbitrary LIMIT 1, and + * so it fails loudly if the mapping has not been configured at all. + */ + private Long ddPayableAccount() { + final Long glAccountId = jdbcTemplate + .queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream() + .findFirst().orElse(null); + if (glAccountId == null) { + throw new IllegalStateException("No Sponsor DD Payable GL account is configured. Set it under " + + "Organization > Sponsor DD GST / GL Settings before approving a dividend claim."); + } + return glAccountId; + } + private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode, final BigDecimal amount, final LocalDate today, final AppUser currentUser) { - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - final Long dividendPaidAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_DIVIDEND_PAID) - .stream().findFirst().orElse(null); - final Long ddPayableAccountId = jdbcTemplate - .queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream() - .findFirst().orElse(null); - if (payableDividendsAccountId == null || ddPayableAccountId == null) { - return; - } + final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount(); + final Long dividendPaidAccountId = dividendGlAccounts.dividendPaidAccountOrNull(); + final Long ddPayableAccountId = ddPayableAccount(); final java.sql.Date entryDate = java.sql.Date.valueOf(today); final String transactionId = "DIVDD" + claimId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index b735ef91a..630665737 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; @@ -39,11 +40,6 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService { - private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - - /** Retained profit, where a dividend nobody claimed returns to. */ - private static final String PROFIT_GL_CODE = "BANKPROFIT01"; - // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2; @@ -53,6 +49,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe private final SavingsAccountAssembler savingsAccountAssembler; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; + private final DividendGlAccountResolver dividendGlAccounts; @Override @Transactional @@ -130,16 +127,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe * declaration-time entries. */ private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) { - final Long payableDividendsAccountId = this.jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - final Long bankProfitAccountId = this.jdbcTemplate - .queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst() - .orElse(null); - if (payableDividendsAccountId == null || bankProfitAccountId == null) { - return; - } + final Long payableDividendsAccountId = this.dividendGlAccounts.payableDividendsAccount(); + final Long bankProfitAccountId = this.dividendGlAccounts.retainedProfitAccount(); final AppUser currentUser = this.context.authenticatedUser(); final LocalDate entryDate = DateUtils.getBusinessLocalDate(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index bc32af30f..f7b06aeb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.shareaccounts.start; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; @@ -115,9 +116,9 @@ public class ShareAccountsConfiguration { @ConditionalOnMissingBean(ShareAccountSchedularService.class) public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository, SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler, - PlatformSecurityContext context, JdbcTemplate jdbcTemplate) { + PlatformSecurityContext context, JdbcTemplate jdbcTemplate, DividendGlAccountResolver dividendGlAccounts) { return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler, - context, jdbcTemplate); + context, jdbcTemplate, dividendGlAccounts); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 7d6b5e5f8..0b7f8fe02 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -39,6 +39,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -77,8 +78,11 @@ public class AllProductsDividendApiResource { private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT"; private static final int DIVIDEND_STATUS_INITIATED = 100; - /** The account a dividend is declared against, once year-end closing has landed the profit in it. */ - private static final String PROFIT_GL_CODE = "BANKPROFITND01"; + /** + * The account a dividend is declared against, once year-end closing has landed the profit in it — configured as + * the Distributable Profit financial activity rather than found by a hardcoded GL code. + */ + private final DividendGlAccountResolver dividendGlAccounts; /** * A dividend is a rate on each member's own paid-up capital, and nothing else. @@ -187,7 +191,7 @@ public class AllProductsDividendApiResource { out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); out.put("ratePercentage", ratePercentage); out.put("totalWeightedCapital", totalWeightedCapital); - out.put("profitGlCode", PROFIT_GL_CODE); + out.put("profitGlCode", distributableProfitGlCode()); // The funding position, not the basis. Carried so a rate the profit account cannot cover is // visible on screen before submission — the command layer enforces the same ceiling, and an // officer should not discover it only when the declaration is rejected. @@ -428,9 +432,9 @@ public class AllProductsDividendApiResource { final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) FROM acc_gl_journal_entry je - JOIN acc_gl_account a ON a.id = je.account_id - WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? - """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(fyStart), + java.sql.Date.valueOf(fyEnd)); return closedProfit == null ? BigDecimal.ZERO : closedProfit; } } @@ -494,6 +498,12 @@ public class AllProductsDividendApiResource { } /** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */ + /** The configured Distributable Profit account's GL code, for display on the declaration screen. */ + private String distributableProfitGlCode() { + return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, + dividendGlAccounts.distributableProfitAccount()); + } + private String financialYearOf(final String isoDate) { final LocalDate date = LocalDate.parse(isoDate); final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index bbfa1822a..cb8117875 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -29,6 +29,7 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandSource; @@ -88,10 +89,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP private final CommandSourceRepository commandSourceRepository; private final JdbcTemplate jdbcTemplate; - // FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole - // accounting module into this constructor just for two int constants. - private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201; - /** * How many financial years of dividend a member may still come and claim. * @@ -100,19 +97,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * money is recognised as income rather than sitting as a liability nobody will ever call on. */ private static final int CLAIMABLE_FINANCIAL_YEARS = 3; - private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301; /** - * The year's closed-out profit a dividend is declared out of; kept in step with - * IncomeProfitTransferService.PROFIT_GL_CODE. + * The dividend GL accounts, read from the Financial Activity Mappings a society configures. + * + * These were hardcoded GL codes (BANKPROFITND01 / BANKPROFIT01) until the whole dividend lifecycle was made + * configurable — see {@link DividendGlAccountResolver} for why the lookup throws on a missing mapping instead of + * posting nothing. */ - private static final String PROFIT_GL_CODE = "BANKPROFITND01"; - - /** - * Where profit settles once a declaration has been approved — the undistributed remainder and the rounding - * residue. Nothing here ever enters a later dividend calculation. - */ - private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01"; + private final DividendGlAccountResolver dividendGlAccounts; // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1; @@ -301,11 +294,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP return; } - final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE); - final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); - if (distributableAccountId == null || settledAccountId == null) { - return; - } + final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount(); + final Long settledAccountId = dividendGlAccounts.retainedProfitAccount(); final String financialYear = financialYearOf(periodEndDate); // Dated to the day of approval, not the period end. Closing off the remainder is an act of @@ -357,14 +347,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue(); final BigDecimal residue = declaredAmount.subtract(allocatedAmount); - // Swept to the settled account, not back to the pool it came from: the residue belongs to the - // year that was just declared, and returning it to the distributable balance would put it - // straight back into what the next declaration measures. - final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); + // Resolved here so a missing mapping is reported while the declaration can still be abandoned, rather than + // first surfacing at approval. Never back to the distributable pool it came from: the residue belongs to the + // year just declared, and returning it there would put it straight into what the next declaration measures. + dividendGlAccounts.dividendRoundOffAccount(); + dividendGlAccounts.payableDividendsAccount(); // A residue of zero still gets its row: "nothing was left over" is itself the answer to the // audit question, and its absence would be indistinguishable from the sweep never running. @@ -411,26 +398,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP return; } - final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - // Left unswept rather than forced through: swept_to_profit_date stays null so the residue is - // still identifiable and can be swept once the GL setup is completed. - if (bankProfitAccountId == null || payableDividendsAccountId == null) { - return; - } + // The round-off account when the society has mapped one, retained profit otherwise — see + // DividendGlAccountResolver#dividendRoundOffAccount for why this one resolves rather than throwing. + final Long roundOffAccountId = dividendGlAccounts.dividendRoundOffAccount(); + final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount(); final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate(); final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); final String transactionId = "DIVROUND" + dividendPayOutId; // Approval credited the whole declared amount to Dividend Payable; this takes back the slice - // that will never be paid to a member and returns it to retained profit. - postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId, + // that will never be paid to a member, because no member's share divided into it evenly. + postJournalEntryPair(payableDividendsAccountId, roundOffAccountId, residue, (String) row.get("currency_code"), transactionId, sweptDate, - "FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId); + "FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept — declaration #" + dividendPayOutId); jdbcTemplate.update(""" UPDATE m_share_dividend_rounding_residue @@ -511,9 +492,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final BigDecimal closedProfit = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) FROM acc_gl_journal_entry je - JOIN acc_gl_account a ON a.id = je.account_id - WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? - """, BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd)); + WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ? + """, BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(fyStart), + java.sql.Date.valueOf(fyEnd)); return closedProfit == null ? BigDecimal.ZERO : closedProfit; } } @@ -543,11 +524,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } - private Long glAccountIdByCode(final String glCode) { - return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst() - .orElse(null); - } - /** * Declaring a dividend charges it against the society's retained profit and creates the obligation to members. * @@ -564,24 +540,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * year-end closing, so the round trip reconciles. * * Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which - * expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting - * Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by - * incomplete GL setup. + * expects a loan/savings/share *transaction* rather than a standalone declaration event. + * + * Both accounts must be mapped; there is no longer a degraded path that posts straight to Dividend Payable when + * Reserve is absent. Skipping the appropriation would put a liability to members on the books at declaration, which + * is exactly what routing through Reserve exists to prevent — so an unmapped Reserve is a setup error to be + * reported, not a variation to be absorbed. */ private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount, final String financialYear) { - final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE); - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - final Long reserveAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) - .stream().findFirst().orElse(null); - if (bankProfitAccountId == null || payableDividendsAccountId == null) { - return; - } + final Long bankProfitAccountId = dividendGlAccounts.distributableProfitAccount(); + final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount(); final AppUser currentUser = this.context.authenticatedUser(); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); @@ -591,12 +560,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP // across years, and without the year on the line the balances only ever give a running total. final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId; - if (reserveAccountId == null) { - postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate, - description); - return; - } - // Only the appropriation posts here. The release to members is a separate act that waits for // approval — see releaseReserveToDividendPayable. Posting both at declaration would put a // liability to members on the books before anyone had approved paying them. @@ -619,20 +582,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside. */ private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) { - final Long reserveAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND) - .stream().findFirst().orElse(null); - final Long payableDividendsAccountId = jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS) - .stream().findFirst().orElse(null); - - // With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend - // Payable, so the liability is on the books and there is nothing left to release. - if (reserveAccountId == null || payableDividendsAccountId == null) { - return; - } + final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount(); + final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount(); // Read back from the row rather than the entity: the amount and product are not exposed as // getters, and this matches how closeOffUndistributedProfit reads the same declaration. @@ -745,11 +696,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP return; } - final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS); - final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE); - if (payableDividendsAccountId == null || bankProfitAccountId == null) { - return; - } + final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount(); + final Long bankProfitAccountId = dividendGlAccounts.retainedProfitAccount(); final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); for (final Map lock : expired) { @@ -769,14 +717,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } } - /** A GL account mapped to a financial activity, or null when the society has not configured it. */ - private Long glAccountIdByFinancialActivity(final int financialActivityType) { - return jdbcTemplate - .queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class, - financialActivityType) - .stream().findFirst().orElse(null); - } - private void makeDividendsClaimable(final Long payOutDetailId) { final LocalDate today = DateUtils.getBusinessLocalDate(); final java.sql.Date lockedDate = java.sql.Date.valueOf(today); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java index 25c028700..e8998825c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/starter/ShareProductsConfiguration.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.shareproducts.starter; import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService; +import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; @@ -117,11 +118,13 @@ public class ShareProductsConfiguration { ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository, ProductPendingEditChargeRepository pendingEditChargeRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, - CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) { + CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate, + DividendGlAccountResolver dividendGlAccounts) { return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper, shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService, businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository, - pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate); + pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate, + dividendGlAccounts); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index ac77c6bb0..5a272502f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -52,11 +52,13 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDRevokeRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.stereotype.Component; @@ -72,6 +74,7 @@ public class SponsorDDApiResource { private final SponsorDDReconciliationService reconciliationService; private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; + private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService; private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; private final PlatformSecurityContext context; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -426,6 +429,49 @@ public class SponsorDDApiResource { return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason())); } + // ── Revoking a leaflet spoiled at the printer ────────────────────────── + + @POST + @Path("leaflets/{leafletId}/revoke-request") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request to revoke a spoiled DD and replace it", + description = "Clerk (maker) step: the leaflet was ruined at the printer, so its draft must move onto the next available blank. Distinct from a reprint, which re-prints the same leaflet.") + public CommandProcessingResult requestRevoke(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) { + return safely(() -> this.revokeRequestWritePlatformService.requestRevoke(leafletId, request.getReason())); + } + + @GET + @Path("revoke-requests") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve DD revoke requests", + description = "Returns all DD revoke requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.") + public Collection retrieveAllRevokeRequests(@QueryParam("status") final String status) { + return this.readPlatformService.retrieveAllRevokeRequests(status); + } + + @POST + @Path("revoke-requests/{revokeRequestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a DD revoke request", + description = "Manager/head-cashier (checker) step: marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. No money moves - the customer was already debited and is still owed the same draft.") + public CommandProcessingResult approveRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId) { + return safely(() -> this.revokeRequestWritePlatformService.approveRevokeRequest(revokeRequestId)); + } + + @POST + @Path("revoke-requests/{revokeRequestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a DD revoke request", + description = "Manager/head-cashier (checker) step: rejects a pending DD revoke request. The leaflet stays issued.") + public CommandProcessingResult rejectRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId, + final RejectIssueRequestRequest request) { + return safely(() -> this.revokeRequestWritePlatformService.rejectRevokeRequest(revokeRequestId, request.getRejectionReason())); + } + // ── Amending an issued DD (beneficiary, payable-at branch and city) ──── @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java index ac63e0d96..4916322df 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -59,4 +59,20 @@ public class SponsorDDLeafletData implements Serializable { private String approverUsername; /** Set while a correction to this draft is awaiting a manager. */ private Long pendingAmendmentRequestId; + + /** Set while a revoke is awaiting a checker, so the register offers it only once. */ + private Long pendingRevokeRequestId; + + /** + * Where a spoiled leaflet's draft went, and which spoiled leaflet a replacement stands in for. + * + *

+ * Both directions are shown in the register: a revoked leaflet reads as consumed stock with a + * pointer to the draft that survived it, and the replacement says what it was issued against. + * Without the pair the pad simply comes up short against the sponsor bank's range. + */ + private Long revokedReplacedByLeafletId; + private String revokedReplacedByDdNumber; + private Long revokedReplacementOfLeafletId; + private String revokedReplacementOfDdNumber; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java new file mode 100644 index 000000000..29ed7fa4c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDRevokeRequestData.java @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A request to replace a leaflet spoiled at the printer. + * + *

+ * Carries the draft's own details as well as the request's, because a checker approving this is + * deciding to consume a blank from limited stock - they need to see whose draft it is and for how + * much without opening the register in another tab. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SponsorDDRevokeRequestData implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + private Long leafletId; + private String ddNumber; + private String reason; + private String status; + + /** Null until approved: no blank is consumed while the request is still pending. */ + private Long replacementLeafletId; + private String replacementDdNumber; + + /** The draft being moved, so the approval queue can be read on its own. */ + private String beneficiaryName; + private String clientName; + private BigDecimal faceValue; + private String sponsorBankName; + + private Long makerId; + private String makerUsername; + private LocalDateTime createdDate; + private Long checkerId; + private String checkerUsername; + private LocalDateTime checkerDate; + private String rejectionReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java index d427ab959..02f9e30ae 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData; public interface SponsorDDReadPlatformService { @@ -59,6 +60,9 @@ public interface SponsorDDReadPlatformService { Collection retrieveAllReprintRequests(String status); + /** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */ + Collection retrieveAllRevokeRequests(String status); + /** DD amendment requests for the approval queue, optionally narrowed to one status. */ Collection retrieveAllAmendmentRequests(String status); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 471e767d9..cf305c1e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -75,6 +76,18 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe } } + /** + * Series order for a book of drafts. + * + * {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 — which handed the + * clerk draft 10 immediately after draft 1 and showed the register out of order. Pre-printed + * stationery has to be consumed in series, so a wholly numeric series is cast and ordered + * numerically; a series carrying a prefix keeps text order, which is already its own sequence. + */ + private static final String LEAFLET_SERIES_ORDER = + "order by case when leaf.dd_number ~ '^[0-9]+$' then cast(leaf.dd_number as bigint) end asc nulls last, " + + "leaf.dd_number asc"; + private static final String LEAFLET_SELECT = "select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, " + "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, " @@ -83,7 +96,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, " + "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, " + "apr.checker_id as approver_user_id, approver.username as approver_username, " - + "amr.id as pending_amendment_request_id " + + "amr.id as pending_amendment_request_id, rvr.id as pending_revoke_request_id, " + + "leaf.revoked_replaced_by_leaflet_id, rby.dd_number as revoked_replaced_by_dd_number, " + + "leaf.revoked_replacement_of_leaflet_id, rof.dd_number as revoked_replacement_of_dd_number " + "from m_sponsor_dd_leaflet leaf " + "left join m_client c on c.id = leaf.issued_to_client_id " + "left join m_office o on o.id = leaf.issuing_branch_id " @@ -94,11 +109,20 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "left join m_appuser approver on approver.id = apr.checker_id " // Lets the registry show that a correction is already awaiting a manager, instead of // offering Edit again and letting a second request be raised over the same draft. - + "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "; + + "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' " + // Same reason as the amendment join: the register must not offer Revoke twice over one + // spoiled leaflet while a checker still has the first request. + + "left join m_sponsor_dd_revoke_request rvr on rvr.leaflet_id = leaf.id and rvr.status = 'PENDING' " + // Both ends of a replacement, so the register can say where a revoked draft went and + // what a replacement was issued against. + + "left join m_sponsor_dd_leaflet rby on rby.id = leaf.revoked_replaced_by_leaflet_id " + + "left join m_sponsor_dd_leaflet rof on rof.id = leaf.revoked_replacement_of_leaflet_id "; @Override public Collection retrieveAllLeafletsByInventory(final Long inventoryId) { - final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc"; + // Numeric-aware, for the same reason as retrieveNextAvailableLeaflet: a text sort shows the + // register as 1, 10, 2, 3 and the book reads as though it were issued out of order. + final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? " + LEAFLET_SERIES_ORDER; return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId); } @@ -170,11 +194,22 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final String approverUsername = rs.getString("approver_username"); final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null ? rs.getLong("pending_amendment_request_id") : null; + final Long pendingRevokeRequestId = rs.getObject("pending_revoke_request_id") != null + ? rs.getLong("pending_revoke_request_id") + : null; + final Long revokedReplacedByLeafletId = rs.getObject("revoked_replaced_by_leaflet_id") != null + ? rs.getLong("revoked_replaced_by_leaflet_id") + : null; + final Long revokedReplacementOfLeafletId = rs.getObject("revoked_replacement_of_leaflet_id") != null + ? rs.getLong("revoked_replacement_of_leaflet_id") + : null; return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId, - approverUserId, approverUsername, pendingAmendmentRequestId); + approverUserId, approverUsername, pendingAmendmentRequestId, pendingRevokeRequestId, + revokedReplacedByLeafletId, rs.getString("revoked_replaced_by_dd_number"), revokedReplacementOfLeafletId, + rs.getString("revoked_replacement_of_dd_number")); } } @@ -414,6 +449,54 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe return this.jdbcTemplate.query(sql, new ReprintRequestMapper()); } + /** + * The request, the draft it is about, and the blank that replaced it. + * + *

+ * Joined out to the leaflet and the client because approving this consumes a blank from limited + * stock: the checker is deciding about a specific customer's draft, not an abstract request id. + */ + private static final String REVOKE_REQUEST_SELECT = + "select rv.id, rv.leaflet_id, leaf.dd_number, rv.reason, rv.status, rv.replacement_leaflet_id, " + + "repl.dd_number as replacement_dd_number, leaf.beneficiary_name, " + + "coalesce(c.display_name, leaf.walkin_purchaser_name) as client_name, leaf.face_value, " + + "sb.name as sponsor_bank_name, rv.maker_id, maker.username as maker_username, rv.created_date, " + + "rv.checker_id, checker.username as checker_username, rv.checker_date, rv.rejection_reason " + + "from m_sponsor_dd_revoke_request rv " + + "join m_sponsor_dd_leaflet leaf on leaf.id = rv.leaflet_id " + + "left join m_sponsor_dd_leaflet repl on repl.id = rv.replacement_leaflet_id " + + "left join m_client c on c.id = leaf.issued_to_client_id " + + "left join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id " + + "left join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id " + + "join m_appuser maker on maker.id = rv.maker_id " + + "left join m_appuser checker on checker.id = rv.checker_id "; + + @Override + public Collection retrieveAllRevokeRequests(final String status) { + if (status != null && !status.isBlank()) { + return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "where rv.status = ? order by rv.id desc", + new RevokeRequestMapper(), status); + } + return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "order by rv.id desc", new RevokeRequestMapper()); + } + + private static final class RevokeRequestMapper implements RowMapper { + + @Override + public SponsorDDRevokeRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + return new SponsorDDRevokeRequestData(rs.getLong("id"), rs.getLong("leaflet_id"), rs.getString("dd_number"), + rs.getString("reason"), rs.getString("status"), + rs.getObject("replacement_leaflet_id") != null ? rs.getLong("replacement_leaflet_id") : null, + rs.getString("replacement_dd_number"), rs.getString("beneficiary_name"), rs.getString("client_name"), + rs.getBigDecimal("face_value"), rs.getString("sponsor_bank_name"), rs.getLong("maker_id"), + rs.getString("maker_username"), + rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null, + rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null, rs.getString("checker_username"), + rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null, + rs.getString("rejection_reason")); + } + } + private static final class ReprintRequestMapper implements RowMapper { @Override public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException { @@ -502,7 +585,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe sql += " AND leaf.advice_status = ? "; params.add(adviceStatus); } - sql += " ORDER BY leaf.dd_number ASC"; + sql += " " + LEAFLET_SERIES_ORDER; return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray()); } @@ -526,7 +609,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe sql.append("and inv.sponsor_bank_id = ? "); params.add(sponsorBankId); } - sql.append("order by inv.id asc, leaf.dd_number asc limit 1"); + // Oldest book first, then strictly up its own series — see LEAFLET_SERIES_ORDER. + sql.append("order by inv.id asc, "); + sql.append(LEAFLET_SERIES_ORDER.substring("order by ".length())); + sql.append(" limit 1"); try { return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java new file mode 100644 index 000000000..4aa8690c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformService.java @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +/** + * Replacing a draft whose leaflet was spoiled at the printer. + * + *

+ * Distinct from a reprint, which puts the same draft through the printer again on the same leaflet. + * Here the paper itself is ruined: its printed number can never be handed to anyone, so the draft + * has to move onto the next blank in the pad. Both leaflets are then accounted for - one spoiled, + * one carrying the draft - which is what lets the branch's stock reconcile against the pad the + * sponsor bank sent. + * + *

+ * No money moves. The customer was debited when the draft was issued and is still owed exactly one + * draft for exactly that amount; only the instrument changes. + */ +public interface SponsorDDRevokeRequestWritePlatformService { + + /** Raised by the branch that spoiled the leaflet. Nothing moves until a checker approves. */ + CommandProcessingResult requestRevoke(Long leafletId, String reason); + + /** + * Marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. + * + * @return the request id, with the replacement leaflet as the sub-entity + */ + CommandProcessingResult approveRevokeRequest(Long revokeRequestId); + + CommandProcessingResult rejectRevokeRequest(Long revokeRequestId, String rejectionReason); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..602fd7476 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDRevokeRequestWritePlatformServiceImpl.java @@ -0,0 +1,288 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +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.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SponsorDDRevokeRequestWritePlatformServiceImpl implements SponsorDDRevokeRequestWritePlatformService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + private static final String LEAFLET_AVAILABLE = "AVAILABLE"; + private static final String LEAFLET_ISSUED = "ISSUED"; + private static final String LEAFLET_REVOKED = "REVOKED"; + + /** + * Series order for pre-printed stationery. + * + *

+ * {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 - which handed out + * draft 10 as the replacement for draft 1 while drafts 2 to 9 sat unused, and left the pad + * consumed out of sequence with no way to reconcile it against the sponsor bank's range. A + * wholly numeric series is cast and ordered numerically; a series carrying a prefix keeps text + * order, which is already its own sequence. + */ + private static final String SERIES_ORDER = + "case when l.dd_number ~ '^[0-9]+$' then cast(l.dd_number as bigint) end asc nulls last, l.dd_number asc"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Override + @Transactional + public CommandProcessingResult requestRevoke(final Long leafletId, final String reason) { + final AppUser currentUser = this.context.authenticatedUser(); + final Map leaflet = findLeafletOrThrow(leafletId); + + if (!LEAFLET_ISSUED.equals(leaflet.get("status"))) { + throw new IllegalStateException( + "Only an issued Demand Draft can be revoked. Current status: " + leaflet.get("status")); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required to revoke a spoiled Demand Draft."); + } + if (!this.jdbcTemplate + .queryForList("SELECT id FROM m_sponsor_dd_revoke_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId) + .isEmpty()) { + throw new IllegalStateException( + "A revoke request is already pending for this Demand Draft. Wait for it to be approved or rejected."); + } + + // Checked at request time as well as at approval: a clerk who has run out of blank drafts + // needs to know now, while the customer is still at the counter, rather than after a + // checker has been asked to approve something that cannot be carried out. + if (findReplacementLeaflet(leafletId) == null) { + throw new IllegalStateException("No blank Demand Draft is available to replace this one - " + + "not in this series, elsewhere in this sponsor bank's stock, or with any other sponsor bank. " + + "Add fresh DD inventory before revoking."); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_sponsor_dd_revoke_request (leaflet_id, reason, status, maker_id, created_date) " + + "VALUES (?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, leafletId); + ps.setString(2, reason); + ps.setString(3, STATUS_PENDING); + ps.setLong(4, currentUser.getId()); + ps.setObject(5, now); + return ps; + }, kh); + return new CommandProcessingResultBuilder().withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build(); + } + + @Override + @Transactional + public CommandProcessingResult approveRevokeRequest(final Long revokeRequestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findRevokeRequestOrThrow(revokeRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own revoke request."); + } + + final Long spoiledId = ((Number) request.get("leaflet_id")).longValue(); + final Map spoiled = findLeafletOrThrow(spoiledId); + if (!LEAFLET_ISSUED.equals(spoiled.get("status"))) { + throw new IllegalStateException( + "This Demand Draft is no longer issued. Current status: " + spoiled.get("status")); + } + + // Re-checked here rather than trusted from request time: stock moves, and the blank that was + // free when the clerk asked may have been issued to someone else in the meantime. + final Map replacement = findReplacementLeaflet(spoiledId); + if (replacement == null) { + throw new IllegalStateException("No blank Demand Draft is available to replace this one. " + + "Add fresh DD inventory, then approve this request."); + } + final Long replacementId = ((Number) replacement.get("id")).longValue(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + + // The draft moves wholesale onto the new leaflet - same customer, same beneficiary, same + // amount. Nothing is recalculated: the customer was debited once, for this draft, and is + // owed exactly this draft on whatever paper the branch can actually print. + this.jdbcTemplate.update("UPDATE m_sponsor_dd_leaflet SET status = ?, face_value = ?, issued_to_client_id = ?, " + + "beneficiary_name = ?, issuing_branch_id = ?, issued_date = ?, walkin_purchaser_name = ?, " + + "walkin_purchaser_address = ?, walkin_purchaser_aadhaar_masked = ?, " + + "revoked_replacement_of_leaflet_id = ?, lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?", + LEAFLET_ISSUED, spoiled.get("face_value"), spoiled.get("issued_to_client_id"), + spoiled.get("beneficiary_name"), spoiled.get("issuing_branch_id"), spoiled.get("issued_date"), + spoiled.get("walkin_purchaser_name"), spoiled.get("walkin_purchaser_address"), + spoiled.get("walkin_purchaser_aadhaar_masked"), spoiledId, currentUser.getId(), now, replacementId); + + // The spoiled leaflet keeps its beneficiary and amount. It is a record of a draft that was + // printed and destroyed, not an empty slot, and blanking it would leave the pad short by one + // with nothing to say where it went. + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_leaflet SET status = ?, revoked_replaced_by_leaflet_id = ?, " + + "lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?", + LEAFLET_REVOKED, replacementId, currentUser.getId(), now, spoiledId); + + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_revoke_request SET status = ?, replacement_leaflet_id = ?, checker_id = ?, " + + "checker_date = ? WHERE id = ?", + STATUS_APPROVED, replacementId, currentUser.getId(), now, revokeRequestId); + + return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).withSubEntityId(replacementId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectRevokeRequest(final Long revokeRequestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findRevokeRequestOrThrow(revokeRequestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own revoke request."); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_revoke_request SET status = ?, checker_id = ?, checker_date = ?, " + + "rejection_reason = ? WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, revokeRequestId); + return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).build(); + } + + /** + * The blank that should carry the draft instead, searched in the order a branch would reach for + * one. + * + *

+ * Nearest first: the next number in the same pad, so the series stays unbroken and the sponsor + * bank's stock reconciles against one continuous range. Then anywhere else in the same sponsor + * bank's stock - a second pad from the same bank is still that bank's paper and its drafts are + * honoured the same way. Only then another sponsor bank, which is a real fallback rather than a + * preference: the draft is drawn on a different bank, so the branch would rather not, but a + * customer standing at the counter needs a draft more than the branch needs a tidy series. + * + *

+ * Null when the branch has no blank draft anywhere, which is a stock problem and is reported as + * one rather than silently issuing nothing. + */ + private Map findReplacementLeaflet(final Long spoiledLeafletId) { + // The next number after the spoiled one, so the pad is consumed in the order it was + // printed. Falls back to the lowest free number in the pad when the spoiled draft was the + // last one, rather than skipping the series entirely. + final List> afterSpoiled = this.jdbcTemplate.queryForList( + "SELECT l.id FROM m_sponsor_dd_leaflet l " + + "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) " + + "AND l.status = ? AND l.id <> ? " + + "AND l.dd_number ~ '^[0-9]+$' " + + "AND cast(l.dd_number as bigint) > " + + " (SELECT case when s.dd_number ~ '^[0-9]+$' then cast(s.dd_number as bigint) else -1 end " + + " FROM m_sponsor_dd_leaflet s WHERE s.id = ?) " + + "ORDER BY " + SERIES_ORDER + " LIMIT 1", + spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId, spoiledLeafletId); + if (!afterSpoiled.isEmpty()) { + return afterSpoiled.get(0); + } + + final List> sameInventory = this.jdbcTemplate.queryForList( + "SELECT l.id FROM m_sponsor_dd_leaflet l " + + "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) " + + "AND l.status = ? AND l.id <> ? " + + "ORDER BY " + SERIES_ORDER + " LIMIT 1", + spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId); + if (!sameInventory.isEmpty()) { + return sameInventory.get(0); + } + + final List> sameBank = this.jdbcTemplate.queryForList( + "SELECT l.id FROM m_sponsor_dd_leaflet l " + + "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id " + + "WHERE i.sponsor_bank_id = (SELECT i2.sponsor_bank_id FROM m_sponsor_dd_inventory i2 " + + " JOIN m_sponsor_dd_leaflet l2 ON l2.inventory_id = i2.id WHERE l2.id = ?) " + + "AND l.status = ? AND l.id <> ? " + + "ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1", + spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId); + if (!sameBank.isEmpty()) { + return sameBank.get(0); + } + + final List> anyBank = this.jdbcTemplate.queryForList( + "SELECT l.id FROM m_sponsor_dd_leaflet l " + + "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id " + + "WHERE l.status = ? AND l.id <> ? ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1", + LEAFLET_AVAILABLE, spoiledLeafletId); + return anyBank.isEmpty() ? null : anyBank.get(0); + } + + private Map findLeafletOrThrow(final Long leafletId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Demand Draft leaflet not found."); + } + return rows.get(0); + } + + private Map findRevokeRequestOrThrow(final Long revokeRequestId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_sponsor_dd_revoke_request WHERE id = ?", revokeRequestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Sponsor DD revoke request not found."); + } + return rows.get(0); + } + + private boolean isCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return true; + } + return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + } + + private void validateCheckerRole(final AppUser user) { + if (!isCheckerRole(user)) { + throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject revoke requests."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java new file mode 100644 index 000000000..d3dff7274 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +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 lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.tds.data.TdsDeducteeData; +import org.apache.fineract.portfolio.tds.service.TdsInterestReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/tds/deposit-interest") +@Component +@Tag(name = "TDS on deposit interest", + description = "Interest credited on deposits in a financial year, per customer, for the section 194A deduction.") +@RequiredArgsConstructor +public class TdsInterestApiResource { + + private final PlatformSecurityContext context; + private final TdsInterestReadPlatformService readPlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Deposit interest per customer for a financial year", + description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.") + public String retrieve(@QueryParam("financialYear") final Integer financialYear) { + this.context.authenticatedUser(); + final List deductees = this.readPlatformService + .retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear()); + return this.toApiJsonSerializer.serialize(deductees); + } + + /** + * The financial year the books are currently in, off the business date rather than the wall + * clock, so a back-dated working day reports the year that day belongs to. + */ + private int currentFinancialYear() { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + return businessDate.getMonthValue() >= 4 ? businessDate.getYear() : businessDate.getYear() - 1; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java new file mode 100644 index 000000000..57819017f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.data; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.List; + +/** + * A customer whose deposit interest the branch must consider for deduction under section 194A, + * together with the accounts and the interest actually credited to them. + * + *

+ * The section bites on time deposits, so savings accounts are carried here as well but marked by + * their own {@code depositTypeCode} - the caller keeps them out of the aggregate and shows them + * separately. Sending only the fixed and recurring deposits would hide from the branch that the + * customer holds a savings balance at all, which is the first thing an officer checks when a + * customer disputes a deduction. + */ +public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, + String branchName, String form15Type, List accounts) implements Serializable { + + /** + * One deposit account and the interest posted to it. + * + * @param depositTypeCode + * Fineract's own code - {@code depositAccountType.fixedDeposit}, + * {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether + * the charge applies at all, so it is sent as the platform states it rather than as + * a flag this report worked out for itself. + */ + public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus, + Long clientId, LocalDate openedOn, List credits) implements Serializable {} + + /** One interest posting: the date it was credited and the amount. */ + public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo) + implements Serializable {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java new file mode 100644 index 000000000..1576d8ec0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.service; + +import java.util.List; +import org.apache.fineract.portfolio.tds.data.TdsDeducteeData; + +public interface TdsInterestReadPlatformService { + + /** + * Deposit interest credited in a financial year, per customer. + * + * @param financialYear + * the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to + * 31 Mar 2027 + */ + List retrieveForFinancialYear(int financialYear); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java new file mode 100644 index 000000000..379a553a0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java @@ -0,0 +1,206 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.service; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.tds.data.TdsDeducteeData; +import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData; +import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reads the interest the bank actually credited, so the deduction is worked out from the ledger + * rather than from a balance and a rate. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService { + + /** + * Interest posting - NOT a deposit, a withdrawal, a fee or a transfer. + * + *

+ * This single condition is what makes the figure an interest figure. A deposit is the + * customer's own money arriving and is not income; a withdrawal and a fee are not income + * either. Only what the bank itself credited as interest can be deducted against, so the + * transaction type is filtered here rather than being inferred later from an amount. + * + * @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING + */ + private static final int INTEREST_POSTING = 3; + + /** + * Every deposit the customer holds, with the interest credited to each inside the year. + * + *

+ * Savings accounts are included deliberately. Section 194A does not reach them, and the caller + * keeps their interest out of the aggregate - but the branch still has to see that the customer + * holds one, and the row on screen shows it as exempt rather than leaving it out entirely. + * + *

+ * Reversed postings are excluded: a reversed posting was never income, and leaving it in would + * deduct tax on interest the customer never received. + */ + private static final String SQL = """ + SELECT c.id AS client_id, + c.account_no AS customer_id, + c.display_name AS name, + c.date_of_birth AS date_of_birth, + k.pan_number AS pan, + k.form15_type AS form15_type, + o.name AS branch_name, + sa.id AS account_id, + sa.account_no AS account_no, + sa.activatedon_date AS opened_on, + CASE sa.deposit_type_enum + WHEN 100 THEN 'depositAccountType.savingsDeposit' + WHEN 200 THEN 'depositAccountType.fixedDeposit' + WHEN 300 THEN 'depositAccountType.recurringDeposit' + ELSE 'depositAccountType.unknown' END AS deposit_type_code, + t.transaction_date AS credit_date, + t.amount AS credit_amount + FROM m_savings_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_kyc k ON k.client_id = c.id + LEFT JOIN m_savings_account_transaction t + ON t.savings_account_id = sa.id + AND t.transaction_type_enum = ? + AND t.is_reversed = false + AND t.transaction_date >= ? + AND t.transaction_date <= ? + WHERE sa.client_id IS NOT NULL + ORDER BY c.display_name, sa.account_no, t.transaction_date + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public List retrieveForFinancialYear(final int financialYear) { + // The Indian financial year, 1 April to 31 March. + final LocalDate from = LocalDate.of(financialYear, 4, 1); + final LocalDate to = LocalDate.of(financialYear + 1, 3, 31); + + final Map byClient = new LinkedHashMap<>(); + + jdbcTemplate.query(SQL, rs -> { + final long clientId = rs.getLong("client_id"); + final Deductee deductee = byClient.computeIfAbsent(clientId, + id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), + readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"), + readString(rs, "form15_type"))); + + final long accountId = rs.getLong("account_id"); + final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id, + readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on"))); + + // Null where the outer join found no posting inside the year: the account is still + // listed, it simply earned nothing in the window. + final java.sql.Date creditDate = rs.getDate("credit_date"); + if (creditDate != null) { + final BigDecimal amount = rs.getBigDecimal("credit_amount"); + account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo)); + } + }, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to)); + + final List result = new ArrayList<>(); + for (final Deductee d : byClient.values()) { + final List accounts = new ArrayList<>(); + for (final Account a : d.accounts.values()) { + // accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses + // to decide whose PAN carries the deduction. Fineract does not model it on the + // savings account, so it is left null and the caller falls back to the account + // holder rather than inventing a joint holder who may not exist. + accounts.add( + new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits)); + } + result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName, + d.form15Type, accounts)); + } + return result; + } + + private static String readString(final ResultSet rs, final String column) { + try { + return rs.getString(column); + } catch (final SQLException e) { + throw new IllegalStateException("Could not read column " + column, e); + } + } + + private static LocalDate readDate(final ResultSet rs, final String column) { + try { + final java.sql.Date value = rs.getDate(column); + return value == null ? null : value.toLocalDate(); + } catch (final SQLException e) { + throw new IllegalStateException("Could not read column " + column, e); + } + } + + /** Mutable accumulators, private to the grouping above. */ + private static final class Deductee { + + private final Long clientId; + private final String customerId; + private final String name; + private final String pan; + private final LocalDate dateOfBirth; + private final String branchName; + private final String form15Type; + private final Map accounts = new LinkedHashMap<>(); + + private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, + String branchName, String form15Type) { + this.clientId = clientId; + this.customerId = customerId; + this.name = name; + this.pan = pan; + this.dateOfBirth = dateOfBirth; + this.branchName = branchName; + this.form15Type = form15Type; + } + } + + private static final class Account { + + private final Long id; + private final String accountNo; + private final String depositTypeCode; + private final LocalDate openedOn; + private final List credits = new ArrayList<>(); + + private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) { + this.id = id; + this.accountNo = accountNo; + this.depositTypeCode = depositTypeCode; + this.openedOn = openedOn; + } + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index e75b002f7..b77490e94 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -612,4 +612,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 6b737c847..0b6a6606c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -67,4 +67,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml new file mode 100644 index 000000000..5b19c15a0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0539_map_profit_financial_activities.xml @@ -0,0 +1,66 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01'; + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml new file mode 100644 index 000000000..39f3d53f2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0544_dividend_round_off_financial_activity.xml @@ -0,0 +1,65 @@ + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01' + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 304 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 304, id FROM acc_gl_account WHERE gl_code = 'DIVROUNDOFF01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml new file mode 100644 index 000000000..12a9f3091 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0545_ensure_profit_financial_activities_mapped.xml @@ -0,0 +1,120 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01' + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 302, id FROM acc_gl_account WHERE gl_code = 'BANKPROFITND01'; + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 303, id FROM acc_gl_account WHERE gl_code = 'BANKPROFIT01'; + + + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 302 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 302, id FROM acc_gl_account + WHERE name = 'Bank Profit (Non-Distributable)' OR gl_code = 'BANKPROFITND01' + ORDER BY id LIMIT 1; + + + + + + SELECT COUNT(*) FROM acc_gl_financial_activity_account WHERE financial_activity_type = 303 + + + INSERT INTO acc_gl_financial_activity_account (financial_activity_type, gl_account_id) + SELECT 303, id FROM acc_gl_account + WHERE name = 'Bank Profit' OR gl_code = 'BANKPROFIT01' + ORDER BY id LIMIT 1; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml new file mode 100644 index 000000000..3c56ff70f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_aml_rule_direction_and_cash_scope.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Describe each seeded rule properly, now that a rule can say more than an amount. + + + UPDATE m_aml_transaction_rule SET cash_only = true + WHERE rule_code IN ('CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L', + 'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K', 'FREQUENT_CASH_NEAR_10L', + 'FREQUENT_LOW_CASH_DEPOSIT', 'FREQUENT_LOW_CASH_WITHDRAWAL', + 'CHARITABLE_ORG_CASH_WITHDRAWAL', 'LOAN_REPAYMENT_IN_CASH'); + + + UPDATE m_aml_transaction_rule SET direction = 'CREDIT' + WHERE rule_code IN ('FREQUENT_LOW_CASH_DEPOSIT', 'STRUCTURING_BELOW_10L', 'STRUCTURING_BELOW_50K', + 'FREQUENT_CASH_NEAR_10L'); + UPDATE m_aml_transaction_rule SET direction = 'DEBIT' + WHERE rule_code IN ('FREQUENT_LOW_CASH_WITHDRAWAL', 'CHARITABLE_ORG_CASH_WITHDRAWAL', + 'LOAN_REPAYMENT_IN_CASH'); + + + UPDATE m_aml_transaction_rule SET min_transaction_count = 3 + WHERE rule_type IN ('STRUCTURING', 'VELOCITY'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml new file mode 100644 index 000000000..05b23bddc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0547_aml_rule_subject_scope.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + Scope the four entity rules to the customers they are actually written about. + + UPDATE m_aml_transaction_rule SET subject_scope = 'PEP' + WHERE rule_code = 'PEP_ACCOUNT_MONITORING'; + + UPDATE m_aml_transaction_rule SET subject_scope = 'CHARITABLE_ORG' + WHERE rule_code = 'CHARITABLE_ORG_CASH_WITHDRAWAL'; + + UPDATE m_aml_transaction_rule SET subject_scope = 'HIGH_RISK_BUSINESS' + WHERE rule_code = 'JEWELLER_ACCOUNT_LARGE_TXN'; + + + UPDATE m_aml_transaction_rule SET subject_scope = 'NEW_ACCOUNT' + WHERE rule_code = 'NEW_ACCOUNT_LARGE_TXN'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml new file mode 100644 index 000000000..56dfdb29a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0548_sponsor_dd_revoke_request.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The approval queue reads by status; the register reads by leaflet. + + + + + + + + + + + + + + + + + + + + Carried on the leaflet as well as on the request, so the register can show "replaced by + DD 000002" without joining through a request row that a purge might one day remove. + + + + + + + + + + + The other direction: a replacement says which spoiled leaflet it stands in for. + + + + + + From 521d61d6b73b98dbf9aa4b365f2eb165b2ef1f67 Mon Sep 17 00:00:00 2001 From: keshav Date: Wed, 16 Sep 2026 12:50:43 +0530 Subject: [PATCH 46/55] feat: AML alert rule disposition, Client Behaviour Window evaluation, Risk Policy parameters & Board Resolution register --- .../infrastructure/jobs/service/JobName.java | 1 + .../portfolio/aml/domain/AmlAlert.java | 42 ++ .../aml/domain/AmlAlertRepository.java | 12 + .../portfolio/aml/domain/AmlAlertRule.java | 163 ++++++++ .../aml/domain/AmlAlertRuleRepository.java | 31 ++ .../domain/AmlRecurringEscalationTier.java | 106 +++++ .../aml/domain/AmlRecurringReview.java | 59 +++ .../portfolio/aml/domain/AmlStrCaseAlert.java | 83 ++++ .../aml/domain/AmlStrCaseAlertRepository.java | 40 ++ .../aml/domain/AmlStrCaseRepository.java | 4 + .../domain/AmlTransactionRuleRepository.java | 7 + .../client/domain/ClientBehaviourWindow.java | 219 ++++++++++ .../ClientBehaviourWindowRepository.java | 43 ++ .../portfolio/client/domain/ClientKyc.java | 58 +++ .../client/domain/RiskPolicyParameter.java | 74 ++++ .../domain/RiskPolicyParameterRepository.java | 23 ++ .../constants/ShareAccountApiConstants.java | 4 +- .../portfolio/aml/api/AlertReviewRequest.java | 10 + .../aml/api/AmlAlertApiResource.java | 8 +- .../aml/api/AmlCtrReportApiResource.java | 11 + .../aml/api/ClientRiskChangeApiResource.java | 172 ++++++++ .../portfolio/aml/data/AmlAlertData.java | 12 +- .../aml/data/AmlMatchedRuleData.java | 43 ++ .../AmlAlertReadPlatformServiceImpl.java | 133 +++++- .../service/AmlAlertWritePlatformService.java | 26 ++ .../AmlAlertWritePlatformServiceImpl.java | 148 +++++++ .../AmlCtrReportGenerationService.java | 19 +- .../AmlCtrReportReadPlatformService.java | 9 + .../AmlCtrReportReadPlatformServiceImpl.java | 89 +++- ...RecurringAlertReadPlatformServiceImpl.java | 8 +- ...ecurringAlertWritePlatformServiceImpl.java | 54 +++ .../service/AmlRuleEvaluationServiceImpl.java | 127 +++++- .../ClientRiskChangeRequestService.java | 46 +++ .../ClientRiskChangeRequestServiceImpl.java | 241 +++++++++++ .../api/ClientBehaviourWindowApiResource.java | 56 +++ .../data/ClientBehaviourWindowData.java | 39 ++ .../client/data/ClientKycApiConstants.java | 22 +- .../portfolio/client/data/ClientKycData.java | 7 + .../ClientBehaviourWindowJobConfig.java | 60 +++ .../ClientBehaviourWindowTasklet.java | 45 ++ ...entBehaviourWindowReadPlatformService.java | 80 ++++ .../service/ClientBehaviourWindowService.java | 247 +++++++++++ .../ClientKycReadPlatformServiceImpl.java | 5 + .../service/RiskMovementPolicyService.java | 276 +++++++++++++ .../api/ShareBorRegisterApiResource.java | 147 +++++++ .../shareaccounts/domain/ShareAccount.java | 25 ++ .../ShareAccountDataSerializer.java | 16 + .../tenant/final-changelog-tenant.xml | 17 + .../parts/0546_board_resolution_register.xml | 129 ++++++ ...9_aml_alert_single_row_per_transaction.xml | 44 ++ .../parts/0550_client_risk_change_request.xml | 78 ++++ .../0551_aml_rule_coverage_classification.xml | 186 +++++++++ .../0552_activate_automatable_aml_rules.xml | 387 ++++++++++++++++++ .../0553_client_behaviour_risk_review.xml | 126 ++++++ .../parts/0554_aml_alert_highest_severity.xml | 48 +++ ...aml_recurring_review_validity_and_tier.xml | 61 +++ .../0555_aml_str_case_covered_alerts.xml | 109 +++++ .../parts/0555_low_severity_aml_rules.xml | 109 +++++ .../parts/0556_aml_alert_rule_disposition.xml | 120 ++++++ .../0557_aml_alert_raised_on_actual_time.xml | 45 ++ .../0558_aml_alert_rule_directly_decided.xml | 58 +++ .../parts/0559_risk_policy_parameters.xml | 109 +++++ .../0560_client_kyc_risk_profile_fields.xml | 81 ++++ .../0561_add_client_behaviour_window_job.xml | 60 +++ .../0562_behaviour_window_distinct_months.xml | 43 ++ 65 files changed, 4928 insertions(+), 32 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 8b98ac771..ee5b62605 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -72,6 +72,7 @@ public enum JobName { OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), // CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), // AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), // + CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), // ; // private final String name; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java index 2d3b1c585..ac63dcd4d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java @@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "disposition", length = 20) private String disposition; + /** + * The other rules this same transaction matched, comma-separated. + * + *

+ * One transaction raises one alert, under the rule that most warrants attention - otherwise a + * single deposit arrives as several alerts and an officer has to dispose of each, with nothing + * stopping them clearing one while escalating another. The rules that did not win are kept here + * so the case file still shows everything that fired. + */ + @Column(name = "also_matched_rules", length = 500) + private String alsoMatchedRules; + + /** + * The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High. + * + *

+ * Not the same as the governing rule's own severity. A statutory CTR outranks everything when + * the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules} + * and vanish from the screen. An officer disposing of low and medium alerts in a batch would + * then be handed a transaction that had tripped a high-severity rule with nothing to show it. + */ + @Column(name = "highest_severity") + private Integer highestSeverity; + protected AmlAlert() {} public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule, @@ -94,6 +118,24 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom { return alert; } + public String getAlsoMatchedRules() { + return this.alsoMatchedRules; + } + + public Integer getHighestSeverity() { + return this.highestSeverity; + } + + public void recordHighestSeverity(final Integer severity) { + this.highestSeverity = severity; + } + + /** Truncated to the column width: the record is a convenience, never a reason to fail a save. */ + public void recordAlsoMatchedRules(final String ruleCodes) { + this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null + : (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes); + } + public void whitelist(AppUser reviewer, String notes) { this.reviewedBy = reviewer; this.reviewedOn = LocalDateTime.now(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index e9eab000b..1fbefbc3f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -44,4 +44,16 @@ public interface AmlAlertRepository extends JpaRepository { * twice, which would put two STR cases in front of the Principal Officer for a single transaction. */ List findByRule_IdAndTransactionId(Long ruleId, Long transactionId); + + /** + * A customer's still-open alerts inside the observation window, oldest first. + * + *

+ * What a recurring-behaviour review acts on. Restricted to RAISED and UNDER_REVIEW because a decision about the + * customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so + * that a partial failure leaves the longest-standing ones dealt with. + */ + @Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since " + + "AND a.status IN ('RAISED', 'UNDER_REVIEW') ORDER BY a.raisedOn ASC") + List findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java new file mode 100644 index 000000000..8886cc528 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java @@ -0,0 +1,163 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * One rule a transaction matched, and what the officer decided about that rule. + * + *

+ * The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by + * the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth + * observation or sends the other five to an STR they do not belong in. + * + *

+ * The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in + * rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule + * afterwards must not rewrite what the officer was looking at when they decided. + */ +@Entity +@Table(name = "m_aml_alert_rule") +public class AmlAlertRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "alert_id", nullable = false) + private AmlAlert alert; + + @Column(name = "rule_code", nullable = false, length = 100) + private String ruleCode; + + @Column(name = "risk_category") + private Integer riskCategory; + + @Column(name = "is_governing", nullable = false) + private boolean governing; + + @Column(name = "disposition", length = 20) + private String disposition; + + @ManyToOne + @JoinColumn(name = "reviewed_by") + private AppUser reviewedBy; + + @Column(name = "reviewed_on") + private LocalDateTime reviewedOn; + + @Column(name = "review_notes", length = 1000) + private String reviewNotes; + + /** + * Whether the officer named this rule, as against it being closed to keep the case consistent. + * + *

+ * Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation - + * nothing may stay open behind a filed STR. Without this the record would read as though all of them had been + * examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is + * the substance of the finding. + */ + @Column(name = "directly_decided", nullable = false) + private boolean directlyDecided; + + protected AmlAlertRule() {} + + public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory, + final boolean governing) { + final AmlAlertRule finding = new AmlAlertRule(); + finding.alert = alert; + finding.ruleCode = ruleCode; + finding.riskCategory = riskCategory; + finding.governing = governing; + return finding; + } + + /** + * @param directlyDecided + * true when the officer named this rule; false when it was closed alongside one they named + */ + public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes, + final boolean directlyDecided) { + this.disposition = disposition.name(); + this.reviewedBy = reviewer; + this.reviewedOn = LocalDateTime.now(); + this.reviewNotes = notes; + this.directlyDecided = directlyDecided; + } + + public boolean isDirectlyDecided() { + return this.directlyDecided; + } + + public boolean isDecided() { + return this.disposition != null; + } + + public boolean isEscalated() { + return AmlAlertDisposition.ESCALATE.name().equals(this.disposition); + } + + public Long getId() { + return this.id; + } + + public AmlAlert getAlert() { + return this.alert; + } + + public String getRuleCode() { + return this.ruleCode; + } + + public Integer getRiskCategory() { + return this.riskCategory; + } + + public boolean isGoverning() { + return this.governing; + } + + public String getDisposition() { + return this.disposition; + } + + public AppUser getReviewedBy() { + return this.reviewedBy; + } + + public LocalDateTime getReviewedOn() { + return this.reviewedOn; + } + + public String getReviewNotes() { + return this.reviewNotes; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java new file mode 100644 index 000000000..0cc7940c4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.domain; + +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AmlAlertRuleRepository extends JpaRepository { + + List findByAlertId(Long alertId); + + /** Every finding across a page of alerts, in one read rather than one query per alert. */ + List findByAlertIdIn(Collection alertIds); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java new file mode 100644 index 000000000..ca99b3158 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.domain; + +/** + * How far a customer's repetition has gone, and what the bank's answer to it has to be. + * + *

+ * A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not + * acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the + * fortieth review cost an officer the same effort as the first and changed as little. + * + *

+ * Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the + * reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried + * about should come back sooner, not later. + */ +public enum AmlRecurringEscalationTier { + + /** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */ + ROUTINE(0, 90), + + /** + * Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and + * the reviewer is asked to consider whether the rating still fits. + */ + ELEVATED(7, 30), + + /** + * Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected + * to be taken by someone senior. + */ + SENIOR(15, 14), + + /** + * Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier + * is a decision that has to be defensible on its own - so the review stands for a week only. + */ + MANDATORY_STR(30, 7); + + private final int minimumAlertCount; + private final int reviewValidityDays; + + AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) { + this.minimumAlertCount = minimumAlertCount; + this.reviewValidityDays = reviewValidityDays; + } + + /** The tier a customer sitting on this many alerts falls into. */ + public static AmlRecurringEscalationTier forAlertCount(final int alertCount) { + AmlRecurringEscalationTier tier = ROUTINE; + for (final AmlRecurringEscalationTier candidate : values()) { + if (alertCount >= candidate.minimumAlertCount) { + tier = candidate; + } + } + return tier; + } + + public int getMinimumAlertCount() { + return minimumAlertCount; + } + + public int getReviewValidityDays() { + return reviewValidityDays; + } + + /** + * How far the alert count may climb before a standing review is set aside and the customer returns early. + * + *

+ * A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four + * alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the + * count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a + * genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated. + */ + public int alertGrowthBeforeEarlyReturn() { + return switch (this) { + case ROUTINE -> 5; + case ELEVATED -> 4; + case SENIOR -> 3; + case MANDATORY_STR -> 2; + }; + } + + /** Whether a review at this tier is expected to raise an STR rather than close the customer off. */ + public boolean expectsStr() { + return this == MANDATORY_STR; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java index dcab2fa94..911b471ab 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java @@ -26,6 +26,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.time.LocalDate; import java.time.LocalDateTime; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.useradministration.domain.AppUser; @@ -65,6 +66,20 @@ public class AmlRecurringReview { @Column(name = "reviewed_on", nullable = false) private LocalDateTime reviewedOn; + /** + * The date this review stops standing. Null on reviews taken before validity existed, which keep the older + * behaviour of lapsing the moment one more alert lands. + */ + @Column(name = "valid_until") + private LocalDate validUntil; + + @Column(name = "escalation_tier", length = 20) + private String escalationTier; + + /** How many of the customer's open alerts this review actually disposed of. */ + @Column(name = "alerts_actioned") + private Integer alertsActioned; + protected AmlRecurringReview() {} public static AmlRecurringReview record(final Client client, final int alertCountAtReview, @@ -76,9 +91,22 @@ public class AmlRecurringReview { review.notes = notes; review.reviewedBy = reviewedBy; review.reviewedOn = LocalDateTime.now(); + + // The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a + // fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by + // always being cleared. + final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview); + review.escalationTier = tier.name(); + review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays()); + review.alertsActioned = 0; return review; } + /** Records how many open alerts the review disposed of, once the caller has acted on them. */ + public void recordAlertsActioned(final int count) { + this.alertsActioned = count; + } + public Long getId() { return id; } @@ -106,4 +134,35 @@ public class AmlRecurringReview { public LocalDateTime getReviewedOn() { return reviewedOn; } + + public LocalDate getValidUntil() { + return validUntil; + } + + public AmlRecurringEscalationTier getEscalationTier() { + return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier); + } + + public Integer getAlertsActioned() { + return alertsActioned; + } + + /** + * Whether this review still holds the customer out of the review queue. + * + *

+ * Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the + * review no longer describes them - judged by how much the alert count has grown since, against the allowance for + * the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of + * lapsing as soon as one more alert arrives. + */ + public boolean stillStands(final int currentAlertCount) { + if (validUntil == null) { + return currentAlertCount <= alertCountAtReview; + } + if (LocalDate.now().isAfter(validUntil)) { + return false; + } + return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn(); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java new file mode 100644 index 000000000..b5168b9cc --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +/** + * One alert covered by an STR case. + * + *

+ * A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour + * covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the + * Principal Officer has to be able to read in one place. + */ +@Entity +@Table(name = "m_aml_str_case_alert") +public class AmlStrCaseAlert { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "str_case_id", nullable = false) + private AmlStrCase strCase; + + @ManyToOne(optional = false) + @JoinColumn(name = "alert_id", nullable = false) + private AmlAlert alert; + + @Column(name = "added_on_utc", nullable = false) + private LocalDateTime addedOn; + + protected AmlStrCaseAlert() {} + + public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) { + final AmlStrCaseAlert link = new AmlStrCaseAlert(); + link.strCase = strCase; + link.alert = alert; + link.addedOn = LocalDateTime.now(); + return link; + } + + public Long getId() { + return id; + } + + public AmlStrCase getStrCase() { + return strCase; + } + + public AmlAlert getAlert() { + return alert; + } + + public LocalDateTime getAddedOn() { + return addedOn; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java new file mode 100644 index 000000000..8c807f10c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AmlStrCaseAlertRepository extends JpaRepository { + + /** The alerts one case covers, oldest first - the order the pattern happened in. */ + List findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId); + + /** + * The case already covering an alert, if any. + * + *

+ * What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second + * one, however it comes to be escalated again. + */ + Optional findByAlert_Id(Long alertId); + + long countByStrCase_Id(Long strCaseId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java index a440216b8..ec1da1e71 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.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; @@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository { long countByClient_Id(Long clientId); + /** The case already open for a transaction, so escalating it again does not open a second one. */ + Optional findByAlertId(Long alertId); + List findByStatus(String status); @Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to") diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java index 19b5eae0b..e7c86daff 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.aml.domain; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository findByRuleCode(String ruleCode); + + /** + * Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched + * into descriptions and severities; doing that a code at a time would be one query per rule per alert. + */ + List findByRuleCodeIn(Collection ruleCodes); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java new file mode 100644 index 000000000..47e6f0a88 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java @@ -0,0 +1,219 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * What one customer's money actually did over a period, and what that says about their rating. + * + *

+ * A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed + * meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone + * - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one. + * Neither is a pattern. + * + *

+ * One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because + * the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of + * it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what + * the system was looking at rather than a conclusion with no working. + */ +@Entity +@Table(name = "m_client_behaviour_window") +public class ClientBehaviourWindow { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "client_id", nullable = false) + private Long clientId; + + @Column(name = "window_from", nullable = false) + private LocalDate windowFrom; + + @Column(name = "window_to", nullable = false) + private LocalDate windowTo; + + @Column(name = "txn_count") + private Integer txnCount; + + @Column(name = "distinct_months") + private Integer distinctMonths; + + @Column(name = "credit_total", precision = 19, scale = 6) + private BigDecimal creditTotal; + + @Column(name = "debit_total", precision = 19, scale = 6) + private BigDecimal debitTotal; + + @Column(name = "largest_txn", precision = 19, scale = 6) + private BigDecimal largestTxn; + + @Column(name = "cash_txn_count") + private Integer cashTxnCount; + + @Column(name = "cash_total", precision = 19, scale = 6) + private BigDecimal cashTotal; + + @Column(name = "large_txn_count") + private Integer largeTxnCount; + + @Column(name = "declared_annual_income", precision = 19, scale = 6) + private BigDecimal declaredAnnualIncome; + + @Column(name = "declared_annual_turnover", precision = 19, scale = 6) + private BigDecimal declaredAnnualTurnover; + + @Column(name = "income_ratio", precision = 19, scale = 6) + private BigDecimal incomeRatio; + + @Column(name = "alert_count") + private Integer alertCount; + + @Column(name = "str_case_count") + private Integer strCaseCount; + + @Column(name = "risk_category_at_review") + private Integer riskCategoryAtReview; + + /** UP, DOWN or HOLD. */ + @Column(name = "suggested_direction", length = 10) + private String suggestedDirection; + + @Column(name = "suggested_category") + private Integer suggestedCategory; + + @Column(name = "suggestion_reason", length = 500) + private String suggestionReason; + + @Column(name = "computed_on") + private LocalDateTime computedOn; + + protected ClientBehaviourWindow() {} + + public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) { + final ClientBehaviourWindow window = new ClientBehaviourWindow(); + window.clientId = clientId; + window.windowFrom = windowFrom; + window.windowTo = windowTo; + window.computedOn = LocalDateTime.now(); + return window; + } + + public void recordDistinctMonths(final Integer months) { + this.distinctMonths = months; + } + + public Integer getDistinctMonths() { + return this.distinctMonths; + } + + public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal, + final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) { + this.txnCount = txnCount; + this.creditTotal = creditTotal; + this.debitTotal = debitTotal; + this.largestTxn = largestTxn; + this.cashTxnCount = cashTxnCount; + this.cashTotal = cashTotal; + this.largeTxnCount = largeTxnCount; + } + + public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) { + this.alertCount = alertCount; + this.strCaseCount = strCaseCount; + this.riskCategoryAtReview = riskCategoryAtReview; + } + + /** + * Turnover measured against what the customer said to expect. + * + *

+ * Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an + * unknown as a benign zero would clear every customer the bank never asked. + */ + public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) { + this.declaredAnnualIncome = income; + this.declaredAnnualTurnover = turnover; + final BigDecimal basis = income != null && income.signum() > 0 ? income + : (turnover != null && turnover.signum() > 0 ? turnover : null); + this.incomeRatio = basis == null || periodTurnover == null ? null + : periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP); + } + + public void recordSuggestion(final String direction, final Integer category, final String reason) { + this.suggestedDirection = direction; + this.suggestedCategory = category; + this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500); + } + + public Long getClientId() { + return this.clientId; + } + + public LocalDate getWindowFrom() { + return this.windowFrom; + } + + public LocalDate getWindowTo() { + return this.windowTo; + } + + public Integer getTxnCount() { + return this.txnCount; + } + + public BigDecimal getCashTotal() { + return this.cashTotal; + } + + public Integer getLargeTxnCount() { + return this.largeTxnCount; + } + + public Integer getAlertCount() { + return this.alertCount; + } + + public Integer getStrCaseCount() { + return this.strCaseCount; + } + + public Integer getRiskCategoryAtReview() { + return this.riskCategoryAtReview; + } + + public String getSuggestedDirection() { + return this.suggestedDirection; + } + + public Integer getSuggestedCategory() { + return this.suggestedCategory; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java new file mode 100644 index 000000000..2abb34e6a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ClientBehaviourWindowRepository extends JpaRepository { + + List findByClientId(Long clientId); + + /** + * Clears the windows computed for a given day before the job writes that day's. + * + *

+ * The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one + * set of windows rather than a customer appearing several times on the review screen with slightly different + * figures. + */ + @Modifying + @Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo") + void deleteByWindowTo(@Param("windowTo") LocalDate windowTo); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 4bbbdf8cc..57d48a9e2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "form15_fy", length = 10) private String form15Fy; + // ── Risk profile ───────────────────────────────────────────────────────── + /** + * Customer facts the risk re-rating policy tests against, as against anything the customer transacted. + * + *

+ * A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the + * risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is + * the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean. + */ + @Column(name = "is_pep", nullable = false) + private boolean isPep; + + @Column(name = "sanctions_match", nullable = false) + private boolean sanctionsMatch; + + @Column(name = "adverse_media_match", nullable = false) + private boolean adverseMediaMatch; + + /** What the customer told the bank to expect, which their actual turnover is measured against. */ + @Column(name = "declared_annual_income", precision = 19, scale = 6) + private BigDecimal declaredAnnualIncome; + + @Column(name = "declared_annual_turnover", precision = 19, scale = 6) + private BigDecimal declaredAnnualTurnover; + // ── CKYCRR ─────────────────────────────────────────────────────────────── /** Central KYC Records Registry number issued by CERSAI */ @Column(name = "ckyc_number", length = 14) @@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { v -> this.aadhaarReceiptRemarks = v); updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges, v -> this.aadhaarReceiptTakenOn = v); + // Risk profile. These are determinations about the customer rather than about a transaction, and the + // re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the + // period's activity looks. + updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false); + updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges, + v -> this.sanctionsMatch = v != null ? v : false); + updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges, + v -> this.adverseMediaMatch = v != null ? v : false); + updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges, + v -> this.declaredAnnualIncome = v); + updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges, + v -> this.declaredAnnualTurnover = v); + updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v); updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v); updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v); @@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { recalculateExpiryForRiskCategory(); } + public boolean isPep() { + return this.isPep; + } + + public boolean isSanctionsMatch() { + return this.sanctionsMatch; + } + + public boolean isAdverseMediaMatch() { + return this.adverseMediaMatch; + } + + public BigDecimal getDeclaredAnnualIncome() { + return this.declaredAnnualIncome; + } + + public BigDecimal getDeclaredAnnualTurnover() { + return this.declaredAnnualTurnover; + } + public String getPanNumber() { return panNumber; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java new file mode 100644 index 000000000..f8862414f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold. + * + *

+ * Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must + * behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact + * three times a month needs different figures from one lending to traders. + */ +@Entity +@Table(name = "m_risk_policy_parameter") +public class RiskPolicyParameter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "parameter_key", nullable = false, length = 80) + private String parameterKey; + + @Column(name = "parameter_value", nullable = false, precision = 19, scale = 2) + private BigDecimal parameterValue; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "last_modified_by") + private Long lastModifiedBy; + + @Column(name = "last_modified_on") + private LocalDateTime lastModifiedOn; + + protected RiskPolicyParameter() {} + + public String getParameterKey() { + return this.parameterKey; + } + + public BigDecimal getParameterValue() { + return this.parameterValue; + } + + public String getDescription() { + return this.description; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java new file mode 100644 index 000000000..3704a0a09 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RiskPolicyParameterRepository extends JpaRepository {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index 265157125..ab32ae97b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -61,6 +61,8 @@ public interface ShareAccountApiConstants { String externalid_paramname = "externalId"; String resolutionnumber_paramname = "resolutionNumber"; + /** Date of the board meeting that passed the resolution. */ + String resolutiondate_paramname = "resolutionDate"; String currency_paramname = "currencyCode"; @@ -146,7 +148,7 @@ public interface ShareAccountApiConstants { Set supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname, id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname, - resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname, + resolutionnumber_paramname, resolutiondate_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname, requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname, minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname, allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java index 075e556eb..24bf24d67 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.aml.api; +import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -30,4 +31,13 @@ public class AlertReviewRequest { private String disposition; private String notes; + + /** + * The rules being decided, when the officer is deciding some of them rather than the whole alert. + * + *

+ * Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level + * Whitelist and Suspicious buttons send and what this endpoint has always done. + */ + private List ruleCodes; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java index 498738d70..e8089278f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java @@ -71,7 +71,13 @@ public class AmlAlertApiResource { description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.") public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) { this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT"); - if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) { + final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition()); + if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) { + // Deciding named rules within the alert. The alert's own status follows from what is left undecided, so + // it is not set here. + this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST", + request.getNotes()); + } else if (escalating) { this.writePlatformService.escalate(alertId, request.getNotes()); } else { this.writePlatformService.whitelist(alertId, request.getNotes()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java index 08289700e..3e51d8bb6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java @@ -72,6 +72,17 @@ public class AmlCtrReportApiResource { return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId)); } + @GET + @Path("pending-lines") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reportable cash transactions not yet in any generated CTR", + description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.") + public String retrievePendingLines() { + this.context.authenticatedUser(); + return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines()); + } + @POST @Path("generate") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java new file mode 100644 index 000000000..bb41d1d2e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java @@ -0,0 +1,172 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.Operation; +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.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService; +import org.springframework.stereotype.Component; + +/** + * Moving a customer's KYC risk category, under maker-checker. + * + *

+ * Raised from the Risk Review Report, which already works out which customers are rated below what + * their behaviour warrants. The rating moves only when a checker approves, and the change is written + * into the customer's risk history with the reason given. + */ +@Path("/v1/aml/risk-change-requests") +@Component +@Tag(name = "Client Risk Category Change", + description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.") +@RequiredArgsConstructor +public class ClientRiskChangeApiResource { + + private final PlatformSecurityContext context; + private final ClientRiskChangeRequestService service; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which + * Fineract's global handler does not map - left uncaught they reach the client as an opaque 500 + * with no message. Wrapped so the reviewer is told what actually stopped them. + */ + private T safely(final Supplier action) { + try { + return action.get(); + } catch (final IllegalArgumentException | IllegalStateException e) { + throw apiError(400, e.getMessage()); + } + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "List risk category change requests", + description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.") + public String retrieveAll(@QueryParam("status") final String status) { + this.context.authenticatedUser(); + return this.gson.toJson(this.service.retrieveAll(status)); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request a change to a customer's risk category", + description = "Maker step. Nothing moves until a checker approves.") + public CommandProcessingResult request(final RiskChangeRequest request) { + return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason())); + } + + @POST + @Path("{requestId}/approve") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Approve a risk category change", + description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.") + public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) { + return safely(() -> this.service.approve(requestId)); + } + + @POST + @Path("{requestId}/reject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.") + public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) { + return safely(() -> this.service.reject(requestId, request.getRejectionReason())); + } + + /** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */ + public static final class RiskChangeRequest { + + private Long clientId; + private Integer requestedCategory; + private String reason; + + public Long getClientId() { + return clientId; + } + + public void setClientId(Long clientId) { + this.clientId = clientId; + } + + public Integer getRequestedCategory() { + return requestedCategory; + } + + public void setRequestedCategory(Integer requestedCategory) { + this.requestedCategory = requestedCategory; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + } + + public static final class RiskChangeRejectRequest { + + private String rejectionReason; + + public String getRejectionReason() { + return rejectionReason; + } + + public void setRejectionReason(String rejectionReason) { + this.rejectionReason = rejectionReason; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java index 9d921270b..cae2fb09c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java @@ -30,6 +30,16 @@ import java.time.LocalDateTime; */ public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName, String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, - String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory) + String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory, + /** Other rules the same transaction matched, comma-separated. Null when only one fired. */ + String alsoMatchedRules, + /** Worst severity across every rule the transaction matched, not only the governing one. */ + Integer highestSeverity, + /** + * Every rule the transaction matched, governing one first, each with its description and severity. The + * comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands + * to show, because a bare rule code does not tell an officer what was detected or how serious it is. + */ + java.util.List matchedRules) implements Serializable { } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java new file mode 100644 index 000000000..58faea72b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.data; + +import java.io.Serializable; + +/** + * One rule a transaction matched, as the alert screen needs to show it. + * + *

+ * An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An + * officer deciding what to do with a transaction has to know what each of those codes means and how serious it is - + * "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision. + * + * @param governing + * whether this is the rule the alert was raised under. One transaction raises one alert, under whichever + * rule most warrants attention, so exactly one entry in the list carries this. + * @param riskCategory + * 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns + * rather than graded suspicions, so they carry no band. + */ +public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing, + /** WHITELIST, ESCALATE, or null while this rule is still open. */ + String disposition, String reviewedByUsername, String reviewNotes, + /** True where the officer named this rule; false where it was closed alongside one they named. */ + boolean directlyDecided) implements Serializable { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java index 7b2652e32..e84e938e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java @@ -18,15 +18,25 @@ */ package org.apache.fineract.portfolio.aml.service; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.aml.data.AmlAlertData; +import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData; import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRule; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ private final AmlAlertRepository repository; private final ClientRiskRuleRepository clientRiskRuleRepository; + private final AmlAlertRuleRepository alertRuleRepository; + private final AmlTransactionRuleRepository transactionRuleRepository; @Override public List retrieve(String statusFilter) { final List alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll() : repository.findByStatusOrderByRaisedOnAsc(statusFilter); + // Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every + // lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so + // without this the screen fails outright at the very moment it has nothing to report. + if (alerts.isEmpty()) { + return List.of(); + } // Resolved once for the whole page rather than per alert: many alerts share a rule, so a // lookup inside the map step would re-read the same catalog rows repeatedly. - final Map riskByRiskRuleId = riskCategoriesFor(alerts); + // Same reasoning for the also-matched rules, which are held on the alert as bare codes: one + // read for every code on the page, not one per alert. + // The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole + // page at once rather than per alert. + final Map> findingsByAlertId = this.alertRuleRepository + .findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream() + .collect(Collectors.groupingBy(f -> f.getAlert().getId())); + final Map rulesByCode = rulesByCodeFor(alerts, findingsByAlertId); + final Map riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values()); return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()) - .map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList()); + .map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList()); } - /** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */ - private Map riskCategoriesFor(final List alerts) { - final List sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct() - .toList(); + /** + * Every rule named anywhere on this page, keyed on its code. + * + *

+ * Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the + * description behind it. + */ + private Map rulesByCodeFor(final List alerts, + final Map> findingsByAlertId) { + final Set codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank()) + .flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty()) + .collect(Collectors.toCollection(java.util.HashSet::new)); + findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add); + alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add); + if (codes.isEmpty()) { + return Map.of(); + } + final Map byCode = new HashMap<>(); + this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule)); + return byCode; + } + + /** + * Every rule the transaction matched, governing one first, each carrying its description and severity. + * + *

+ * A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the + * officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was + * raised - keeps its code as the description, because the alert is a record of what happened and editing the rule + * set afterwards does not change that. + */ + private List matchedRulesOf(final AmlAlert alert, final Map riskByRiskRuleId, + final Map rulesByCode, final Map> findingsByAlertId) { + final List findings = findingsByAlertId.get(alert.getId()); + if (findings != null && !findings.isEmpty()) { + // Governing rule first, then by code, so the pane does not reorder itself between reads. + return findings.stream() + .sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode)) + .map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(), + f.isGoverning(), f.getDisposition(), + f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(), + f.isDirectlyDecided())) + .collect(Collectors.toList()); + } + + // No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat + // fields so the pane still shows what fired, with no per-rule disposition to report. + final List matched = new ArrayList<>(); + final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId(); + matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(), + governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false)); + + final String also = alert.getAlsoMatchedRules(); + if (also == null || also.isBlank()) { + return matched; + } + for (final String raw : also.split(",")) { + final String code = raw.trim(); + if (code.isEmpty()) { + continue; + } + final AmlTransactionRule rule = rulesByCode.get(code); + final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId(); + matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode), + sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false)); + } + return matched; + } + + /** + * A rule's description, or its code when the rule itself is gone. + * + *

+ * An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code + * stands in rather than the row disappearing. + */ + private String describe(final String ruleCode, final Map rulesByCode) { + final AmlTransactionRule rule = rulesByCode.get(ruleCode); + return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription(); + } + + /** + * Severity of each originating catalog rule, keyed on m_client_risk_rule.id. + * + *

+ * Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every + * also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show. + */ + private Map riskCategoriesFor(final List alerts, final Collection alsoMatched) { + final List sourceIds = Stream + .concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()), + alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId)) + .filter(id -> id != null).distinct().toList(); final Map byId = new HashMap<>(); if (sourceIds.isEmpty()) { return byId; @@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ return byId; } - private AmlAlertData mapToData(AmlAlert alert, Map riskByRiskRuleId) { + private AmlAlertData mapToData(AmlAlert alert, Map riskByRiskRuleId, Map rulesByCode, + Map> findingsByAlertId) { final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId(); final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId); return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(), alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(), alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(), alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(), - alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory); + alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory, + alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java index 80727b4cd..0eba4fbb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.aml.service; +import java.util.List; import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlStrCase; @@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService { AmlAlert whitelist(Long alertId, String notes); AmlStrCase escalate(Long alertId, String notes); + + /** + * Escalates several alerts into a single STR case. + * + *

+ * For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert + * separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a + * pattern that only means anything taken together - and a customer reviewed again a month later would add several + * more. An STR to FIU-IND reports the activity, so the case has to hold all of it. + * + * @param alertIds + * the alerts the pattern is made of; any already covered by a case keep that case + * @return the case covering them, or null when every alert was already covered + */ + AmlStrCase escalateAsOneCase(List alertIds, String notes); + + /** + * Decides individual rules within one alert, leaving the rest open. + * + * @param ruleCodes + * the rules being decided now + * @param disposition + * WHITELIST or ESCALATE, applied to each of them + */ + AmlAlert reviewRules(Long alertId, List ruleCodes, String disposition, String notes); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java index e3ed3c725..3fe7845f0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java @@ -19,12 +19,21 @@ package org.apache.fineract.portfolio.aml.service; import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.aml.domain.AmlAlert; +import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRule; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository; +import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus; import org.apache.fineract.portfolio.aml.domain.AmlStrCase; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository; +import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional; public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService { private final AmlAlertRepository alertRepository; + private final AmlAlertRuleRepository alertRuleRepository; private final AmlStrCaseRepository strCaseRepository; + private final AmlStrCaseAlertRepository strCaseAlertRepository; private final PlatformSecurityContext context; private final ClientRiskEscalationService clientRiskEscalationService; @@ -42,20 +53,157 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe public AmlAlert whitelist(Long alertId, String notes) { final AmlAlert alert = loadAlert(alertId); alert.whitelist(context.authenticatedUser(), notes); + decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true); return alertRepository.save(alert); } @Override public AmlStrCase escalate(Long alertId, String notes) { final AmlAlert alert = loadAlert(alertId); + final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus()); alert.escalate(context.authenticatedUser(), notes); + decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true); alertRepository.save(alert); + // An STR is about the transaction, so an alert already escalated - from the row or from a rule inside it - + // keeps the case it already has rather than opening a second one against the same transaction. + if (wasEscalated) { + return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert)); + } + return openStrCase(alert); + } + + private AmlStrCase openStrCase(final AmlAlert alert) { final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert)); + // Recorded in the link table as well as on alert_id, so every case describes what it covers the same way - + // a single-transaction case covering one alert, a pattern case covering several. + strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert)); clientRiskEscalationService.onStrCaseEscalated(strCase); return strCase; } + @Override + public AmlStrCase escalateAsOneCase(final List alertIds, final String notes) { + if (alertIds == null || alertIds.isEmpty()) { + return null; + } + + final AppUser currentUser = context.authenticatedUser(); + AmlStrCase strCase = null; + + for (final Long alertId : alertIds) { + final AmlAlert alert = loadAlert(alertId); + + // An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice + // under two references, and the older case is the one an officer may already have worked on. + final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null); + if (existing != null) { + if (strCase == null) { + strCase = existing.getStrCase(); + } + continue; + } + + alert.escalate(currentUser, notes); + decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true); + alertRepository.save(alert); + + if (strCase == null) { + // Opened from the first alert that needed one, which is the oldest: the case is dated and referenced + // from where the pattern starts rather than from whichever alert happened to be escalated last. + strCase = strCaseRepository.save(AmlStrCase.openFor(alert)); + clientRiskEscalationService.onStrCaseEscalated(strCase); + } + strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert)); + } + + return strCase; + } + + /** + * Records a decision against individual rules within one alert. + * + *

+ * A transaction can trip six rules where five are explained by the customer's declared business and the sixth is + * not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays + * open while any rule is undecided, because a case with an open finding is not a closed case. + * + *

+ * Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not + * about each rule that described it. Escalating a second rule on an alert already escalated adds the finding + * without opening a duplicate case. + */ + @Override + public AmlAlert reviewRules(final Long alertId, final List ruleCodes, final String disposition, final String notes) { + final AmlAlert alert = loadAlert(alertId); + final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition); + final List findings = alertRuleRepository.findByAlertId(alertId); + + final Set requested = Set.copyOf(ruleCodes); + final List targeted = findings.stream().filter(f -> requested.contains(f.getRuleCode())).toList(); + if (targeted.isEmpty()) { + throw new IllegalArgumentException("No matching rules on alert " + alertId); + } + for (final AmlAlertRule finding : targeted) { + finding.decide(decision, context.authenticatedUser(), notes, true); + alertRuleRepository.save(finding); + } + + // Re-read state across every finding, including the ones just written, to decide where the alert now stands. + final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated); + final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus()); + + // Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant + // an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely + // because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One + // suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case + // that is already open. + if (anyEscalated) { + alert.escalate(context.authenticatedUser(), notes); + // The findings left open were carried into the escalation, so nothing is left dangling behind a decided + // case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were + // closed to keep the case consistent, and the record must not claim otherwise. + decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false); + alertRepository.save(alert); + if (!wasEscalated) { + final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert)); + clientRiskEscalationService.onStrCaseEscalated(strCase); + } + return alert; + } + + // Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while + // one of its rules is still unexamined. + // + // Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion - + // it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never + // offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable, + // waiting on a judgement that is not the officer's to make. + if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) { + return alertRepository.save(alert); + } + alert.whitelist(context.authenticatedUser(), notes); + return alertRepository.save(alert); + } + + /** + * Carries a whole-alert decision down to every finding still open beneath it. + * + *

+ * Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings + * blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings + * already decided individually keep the decision the officer made about them. + */ + private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes, + final boolean directlyDecided) { + for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) { + if (!finding.isDecided()) { + finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided); + alertRuleRepository.save(finding); + } + } + } + private AmlAlert loadAlert(Long alertId) { return alertRepository.findById(alertId) .orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java index 903ab2332..0c5096eab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java @@ -26,11 +26,13 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository; +import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus; import org.apache.fineract.portfolio.aml.domain.AmlTransactionType; import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository; import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository; @@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService { public AmlCtrReport generateForMonth(YearMonth month) { final String reportMonth = month.toString(); - if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) { - throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated"); + // A month can be rebuilt while its report is still a draft, and must not be once it has been submitted. + // Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash + // transaction posted after it ran belonged to that month's filing and could never be added to it - the report + // was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its + // lines with what the month now holds; a submitted report is left exactly as filed. + final Optional existing = ctrReportRepository.findByReportMonth(reportMonth); + if (existing.isPresent()) { + final AmlCtrReport report = existing.get(); + if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) { + throw new IllegalStateException( + "CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated"); + } + ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId())); + ctrReportRepository.delete(report); + ctrReportRepository.flush(); } final LocalDate start = month.atDay(1); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java index 6a1dfabcf..fe768bb00 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java @@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService { * one month's report, or null for every generated month */ List retrieveLines(Long reportId); + + /** + * Reportable cash transactions that no generated report contains yet. + * + *

+ * The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no + * report until the month is regenerated. These are those transactions, so a reportable one is never invisible. + */ + List retrievePendingLines(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java index ecd0dc29e..5d95faa95 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.aml.service; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -28,6 +29,7 @@ import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,6 +70,56 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id """; + /** + * Cash transactions that a CTR must name but that no generated report contains yet. + * + *

+ * The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs + * to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks + * to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is + * listed here as pending: shaped exactly like a generated line, with no report behind it. + * + *

+ * Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and + * cannot be decided while the month is still running. + */ + private static final String PENDING_LINES_SQL = """ + SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id, + to_char(t.transaction_date, 'YYYY-MM') AS report_month, + 'SAVINGS' AS transaction_type, t.id AS transaction_id, + c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group, + sa.account_no AS account_no, o.name AS branch_name, t.transaction_date, + CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit' + WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction, + sa.activatedon_date AS account_opened_on, + sa.closedon_date AS account_closed_on, + CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED' + WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED' + WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED' + WHEN 800 THEN 'MATURED' END AS account_status, + k.ckyc_number AS ckyc_number, + COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id, + COALESCE(c.legal_form_enum, 1) = 1 AS individual + FROM m_savings_account_transaction t + JOIN m_savings_account sa ON sa.id = t.savings_account_id + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_kyc k ON k.client_id = c.id + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id + JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE pt.is_cash_payment = true + AND t.is_reversed = false + AND t.transaction_type_enum IN (1, 2) + AND t.amount >= ? + AND NOT EXISTS ( + SELECT 1 FROM m_aml_ctr_report_line el + WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id) + ORDER BY t.transaction_date DESC, t.amount DESC, t.id + """; + + /** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */ + private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000"); + private final AmlCtrReportRepository repository; private final JdbcTemplate jdbcTemplate; @@ -88,18 +140,35 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat // Largest first: the line most likely to be questioned is the one the reviewer reads first. sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id "); - return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"), - rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"), - rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"), - rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"), - rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), - rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"), - rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(), - rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(), - rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"), - rs.getBoolean("individual")), args.toArray()); + return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray()); } + /** + * Reportable cash transactions no generated report contains yet. + * + *

+ * Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they + * are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which + * is what makes them disappear from this list. + */ + @Override + public List retrievePendingLines() { + return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD); + } + + /** Shared by both queries, which select the same columns so a pending line renders like a filed one. */ + private static final RowMapper LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData( + rs.getObject("id") == null ? null : rs.getLong("id"), + rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"), + rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), + rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"), + rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"), + rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"), + rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(), + rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(), + rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"), + rs.getBoolean("individual")); + private AmlCtrReportData mapToData(AmlCtrReport report) { return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(), report.getFileReference(), report.getStatus().name(), report.getSubmittedOn()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java index 3dcd8ff0b..bedff9209 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java @@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle final List alerts = byClient.get(clientId); final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst() .orElse(null); - // Reviewed at a lower count means new alerts have landed since — the customer has to - // come back, otherwise one early review would silence them permanently. - final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview(); + // A standing review holds the customer out until its window runs out, or until their behaviour has moved + // on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on + // the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back + // the day after being reviewed, for the officer to re-read the explanation they had just written. + final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size()); if (onlyNeedingReview && !needsReview) { continue; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java index a0ce79771..ccf46f84a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.aml.service; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; @@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.aml.domain.AmlAlert; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview; import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome; import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository; @@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl private final AmlRecurringAlertReadPlatformService readPlatformService; private final ClientRepositoryWrapper clientRepository; private final PlatformSecurityContext context; + private final AmlAlertRepository alertRepository; + private final AmlAlertWritePlatformService alertWritePlatformService; @Override @Transactional @@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(), this.context.authenticatedUser()); + + // The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row + // and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an + // STR while the alerts sat untouched and no case ever reached the Principal Officer. + final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window); + review.recordAlertsActioned(actioned); + return this.reviewRepository.saveAndFlush(review).getId(); } + /** + * Carries the reviewer's conclusion through to the customer's open alerts. + * + *

+     *   CLEARED    every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
+     *   ESCALATED  every open alert is escalated, which opens an STR case for the Principal Officer
+     *   MONITOR    nothing is disposed of - the point of monitoring is that the alerts stay open
+     * 
+ * + * Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the + * audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any + * of that here would be a second, quieter path to the same records. + */ + private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes, + final int windowDays) { + + if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) { + return 0; + } + + final LocalDateTime since = LocalDateTime.now().minusDays(windowDays); + final List openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since); + + final String trail = "Recurring-behaviour review: " + notes; + + if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) { + // One case for the whole pattern, not one per transaction. What is being reported is the customer's + // repeated activity, and the Principal Officer investigating it needs the transactions that make it up + // together - a case per alert would hand them four fragments of one story, and five more the next time + // this customer was reviewed. + this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail); + return openAlerts.size(); + } + + int actioned = 0; + for (final AmlAlert alert : openAlerts) { + this.alertWritePlatformService.whitelist(alert.getId(), trail); + actioned++; + } + return actioned; + } + private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) { final List errors = new ArrayList<>(); final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 2c8023e39..60e8f940a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -20,17 +20,27 @@ package org.apache.fineract.portfolio.aml.service; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.aml.data.AmlDirection; import org.apache.fineract.portfolio.aml.data.AmlTransactionContext; import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRule; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository; import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo; import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.client.domain.ClientKycRepository; +import org.apache.fineract.portfolio.client.domain.ClientRiskRule; +import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -51,9 +61,39 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { private static final int MEDIUM_RISK = 2; private static final int HIGH_RISK = 3; + /** + * Rules that carry a filing obligation in their own right, whatever else the transaction also + * matched. + * + *

+ * A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or + * above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a + * behavioural rule outrank it would bury a statutory filing behind an internal observation. + */ + private static final Set STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L", + "STRUCTURED_CTR_10L_MONTHLY"); + + /** + * Which rule an alert is raised under when a transaction matches several. + * + *

+ * Statutory reporting first, because those must be filed. Then the largest threshold, which is + * the system's own measure of how far out of the ordinary the transaction is - a rule written + * for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last, + * so the same transaction always reports under the same rule rather than shifting with whatever + * order the database returned. + */ + private static final Comparator RULE_PRECEDENCE = Comparator + .comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1) + .thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(), + Comparator.reverseOrder()) + .thenComparing(AmlTransactionRule::getRuleCode); + private final AmlTransactionRuleRepository ruleRepository; private final AmlAlertRepository alertRepository; + private final AmlAlertRuleRepository alertRuleRepository; private final ClientKycRepository clientKycRepository; + private final ClientRiskRuleRepository clientRiskRuleRepository; private final AmlRecurringAlertNotificationService recurringAlertNotificationService; private final AmlSubjectScopeResolver subjectScopeResolver; @@ -63,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name())); candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name())); - final List raised = new ArrayList<>(); + final List matched = new ArrayList<>(); for (final AmlTransactionRule rule : candidates) { if (isHit(rule, context)) { - final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule, - context.getAmount(), context.getCurrencyCode(), context.getOccurredOn()); - raised.add(alertRepository.save(alert)); + matched.add(rule); } } + final List raised = new ArrayList<>(); + if (!matched.isEmpty()) { + // One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000 + // cash deposit arrived as two open alerts - the CTR and the new-account rule - and the + // officer had to dispose of each. Nothing stopped them whitelisting one while + // escalating the other, leaving the same deposit recorded as both cleared and + // suspicious. A monitoring system reports a transaction once, under the rule that most + // warrants attention. + matched.sort(RULE_PRECEDENCE); + final AmlTransactionRule governing = matched.get(0); + + // When the alert was raised, not the transaction's value date. The context carries the date the + // transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read + // "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it + // noticed something; the value date is still on the transaction the alert points at. + final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), + governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant()); + // The rules that did not govern are still recorded: an analyst needs to see that the + // deposit was both reportable and on a new account, even though it is one case. + if (matched.size() > 1) { + alert.recordAlsoMatchedRules( + matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", "))); + } + // The worst band across everything that matched, not only the rule the alert was raised + // under. A statutory CTR outranks a high-severity behavioural rule when the governing + // rule is picked, so without this the screen would see "CTR, no severity" and offer the + // transaction up for a batch decision alongside ordinary low-value alerts. + final Map bandBySourceId = severityBySourceRuleId(matched); + alert.recordHighestSeverity(highestSeverityOf(bandBySourceId)); + final AmlAlert saved = alertRepository.save(alert); + + // One finding per rule, so each can be disposed of on its own. Severity is copied in + // rather than read back through the rule later: the alert records what was detected at + // the time, and re-banding a rule afterwards must not rewrite that. + for (final AmlTransactionRule rule : matched) { + final Long sourceId = rule.getSourceClientRiskRuleId(); + alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(), + sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing)); + } + raised.add(saved); + } + // Every transaction type funnels through here, so the recurring-behaviour check is done once // in one place rather than repeated in each of the savings / loan / transfer callers. Passing // the batch size lets the service tell a first-time offender from a repeat one without @@ -82,6 +162,45 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { return raised; } + /** + * The worst severity band among the rules that matched. + * + *

+ * A transaction rule carries no severity of its own; it inherits the risk category of the + * catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute + * nothing, because a statutory return is not a graded suspicion. Null when none of the matched + * rules carried a severity at all. + */ + private Integer highestSeverityOf(final Map bandBySourceId) { + Integer worst = null; + for (final Integer band : bandBySourceId.values()) { + if (band != null && (worst == null || band > worst)) { + worst = band; + } + } + return worst; + } + + /** + * Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}. + * + *

+ * Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written + * beneath it - rather than querying the catalog twice for the same rows. + */ + private Map severityBySourceRuleId(final List matched) { + final List sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null) + .distinct().toList(); + if (sourceIds.isEmpty()) { + return Map.of(); + } + final Map bandById = new HashMap<>(); + for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) { + bandById.put(catalogRule.getId(), catalogRule.getRiskCategory()); + } + return bandById; + } + private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) { if (!appliesToThisTransaction(rule, context)) { return false; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java new file mode 100644 index 000000000..b2ef29690 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.service; + +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +/** + * Moving a customer's KYC risk category, under maker-checker. + * + *

+ * The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so + * raising or lowering one is a decision about how closely the bank watches that customer. It is + * raised from the Risk Review Report - which already works out where a rating ought to sit - and + * takes effect only when a checker agrees. + */ +public interface ClientRiskChangeRequestService { + + /** Raised by the reviewer. Nothing moves until a checker approves. */ + CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason); + + /** Applies the new category to the customer's KYC record and records the change in history. */ + CommandProcessingResult approve(Long requestId); + + CommandProcessingResult reject(Long requestId, String rejectionReason); + + /** @param status PENDING / APPROVED / REJECTED, or null for all */ + List> retrieveAll(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java new file mode 100644 index 000000000..ae0d13ba5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java @@ -0,0 +1,241 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.aml.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +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.portfolio.aml.domain.ClientRiskCategoryHistory; +import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_APPROVED = "APPROVED"; + private static final String STATUS_REJECTED = "REJECTED"; + + /** KycRiskCategory: 1 Low, 2 Medium, 3 High. */ + private static final int LOWEST_CATEGORY = 1; + private static final int HIGHEST_CATEGORY = 3; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final ClientRepositoryWrapper clientRepository; + private final ClientKycRepositoryWrapper kycRepositoryWrapper; + private final ClientRiskCategoryHistoryRepository historyRepository; + + @Override + @Transactional + public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) { + final AppUser currentUser = this.context.authenticatedUser(); + + if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) { + throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3)."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("A reason is required to change a customer's risk category."); + } + + final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId); + final Integer currentCategory = kyc.getRiskCategoryValue(); + + if (requestedCategory.equals(currentCategory)) { + throw new IllegalStateException("The customer is already on that risk category."); + } + if (!this.jdbcTemplate + .queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId) + .isEmpty()) { + throw new IllegalStateException( + "A risk category change is already pending for this customer. Wait for it to be approved or rejected."); + } + + final KeyHolder kh = new GeneratedKeyHolder(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update(con -> { + final java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_client_risk_change_request " + + "(client_id, current_category, requested_category, reason, status, maker_id, created_date) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, client.getId()); + if (currentCategory == null) { + ps.setNull(2, java.sql.Types.INTEGER); + } else { + ps.setInt(2, currentCategory); + } + ps.setInt(3, requestedCategory); + ps.setString(4, reason); + ps.setString(5, STATUS_PENDING); + ps.setLong(6, currentUser.getId()); + ps.setObject(7, now); + return ps; + }, kh); + + return new CommandProcessingResultBuilder().withClientId(client.getId()) + .withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build(); + } + + @Override + @Transactional + public CommandProcessingResult approve(final Long requestId) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findRequestOrThrow(requestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot approve your own risk category change."); + } + + final Long clientId = ((Number) request.get("client_id")).longValue(); + final Integer requestedCategory = ((Number) request.get("requested_category")).intValue(); + + final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId); + + // Read again rather than trusted from the request: the rating may have moved since - an AML + // auto-escalation on the customer's third STR case does exactly that - and the history has + // to record where it actually moved from. + final Integer previousCategory = kyc.getRiskCategoryValue(); + if (requestedCategory.equals(previousCategory)) { + throw new IllegalStateException( + "The customer is already on the requested risk category; the rating moved after this request was raised."); + } + + // Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher + // is called back in sooner, which is most of what the rating is for. + kyc.escalateRiskCategory(requestedCategory); + this.kycRepositoryWrapper.saveAndFlush(kyc); + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory, + (String) request.get("reason"), currentUser, now)); + + this.jdbcTemplate.update( + "UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, requestId); + + return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build(); + } + + @Override + @Transactional + public CommandProcessingResult reject(final Long requestId, final String rejectionReason) { + final AppUser currentUser = this.context.authenticatedUser(); + validateCheckerRole(currentUser); + + final Map request = findRequestOrThrow(requestId); + if (!STATUS_PENDING.equals(request.get("status"))) { + throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status")); + } + final Long makerId = ((Number) request.get("maker_id")).longValue(); + if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) { + throw new IllegalStateException("You cannot reject your own risk category change."); + } + + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + this.jdbcTemplate.update( + "UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? " + + "WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId); + + return new CommandProcessingResultBuilder().withEntityId(requestId).build(); + } + + @Override + public List> retrieveAll(final String status) { + final StringBuilder sql = new StringBuilder(""" + SELECT r.id AS "id", + r.client_id AS "clientId", + c.display_name AS "clientName", + c.account_no AS "cifNumber", + o.name AS "officeName", + r.current_category AS "currentCategory", + r.requested_category AS "requestedCategory", + r.reason AS "reason", + r.status AS "status", + maker.username AS "makerUsername", + to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate", + checker.username AS "checkerUsername", + to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate", + r.rejection_reason AS "rejectionReason" + FROM m_client_risk_change_request r + JOIN m_client c ON c.id = r.client_id + LEFT JOIN m_office o ON o.id = c.office_id + JOIN m_appuser maker ON maker.id = r.maker_id + LEFT JOIN m_appuser checker ON checker.id = r.checker_id + WHERE 1 = 1 + """); + final List args = new ArrayList<>(); + if (status != null && !status.isBlank()) { + sql.append(" AND r.status = ? "); + args.add(status); + } + sql.append(" ORDER BY r.id DESC "); + return this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + } + + private Map findRequestOrThrow(final Long requestId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("Risk category change request not found."); + } + return rows.get(0); + } + + /** + * The same checker rule the rest of the AML module uses: a rating change is a compliance + * decision, so it is approved by a manager rather than by whoever happens to be at the screen. + */ + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("principal officer") || n.contains("compliance")); + if (!isChecker) { + throw new IllegalStateException( + "Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java new file mode 100644 index 000000000..ae86b6277 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData; +import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/risk-review/behaviour-windows") +@Component +@Tag(name = "Client Behaviour Windows", description = "What each customer's money did over the review period, and the rating that suggests.") +@RequiredArgsConstructor +public class ClientBehaviourWindowApiResource { + + private final PlatformSecurityContext context; + private final ClientBehaviourWindowReadPlatformService readPlatformService; + private final ToApiJsonSerializer toApiJsonSerializer; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "The latest behaviour window for each customer", + description = "One row per customer, carrying the period's figures and the rating the policy suggests from them. Computed nightly; the screen shows what the suggestion was drawn from rather than recomputing it.") + public String retrieveAll() { + this.context.authenticatedUser(); + final List windows = this.readPlatformService.retrieveLatestPerClient(); + return this.toApiJsonSerializer.serialize(windows); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java new file mode 100644 index 000000000..272528b56 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * One customer's review period as the Risk Review screen shows it. + * + *

+ * The figures travel with the suggestion rather than being recomputed by the screen. A recommendation an officer is + * asked to approve has to show what it was drawn from - months later, the same row must still explain itself - and a + * second calculation in the browser could disagree with the one the policy actually used. + */ +public record ClientBehaviourWindowData(Long clientId, String clientName, String accountNo, String officeName, + LocalDate windowFrom, LocalDate windowTo, Integer txnCount, Integer distinctMonths, BigDecimal creditTotal, + BigDecimal debitTotal, BigDecimal largestTxn, Integer cashTxnCount, BigDecimal cashTotal, Integer largeTxnCount, + BigDecimal declaredAnnualIncome, BigDecimal incomeRatio, Integer alertCount, Integer strCaseCount, + Integer riskCategoryAtReview, String suggestedDirection, Integer suggestedCategory, String suggestionReason) + implements Serializable { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java index a45882416..ac31daa26 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java @@ -97,6 +97,20 @@ public final class ClientKycApiConstants { public static final String form15ApplicableParam = "form15Applicable"; public static final String form15TypeParam = "form15Type"; public static final String form15FYParam = "form15FY"; + /** + * Customer facts the risk re-rating policy tests against. + * + *

+ * A politically exposed person is not downgraded because their activity looks ordinary - that is precisely the + * risk - and the same holds for a sanctions or adverse-media match. The declared figures are what "turnover out of + * keeping with the customer's stated income" is measured against. + */ + public static final String isPepParam = "isPep"; + public static final String sanctionsMatchParam = "sanctionsMatch"; + public static final String adverseMediaMatchParam = "adverseMediaMatch"; + public static final String declaredAnnualIncomeParam = "declaredAnnualIncome"; + public static final String declaredAnnualTurnoverParam = "declaredAnnualTurnover"; + public static final String isExceptionParam = "isException"; public static final String exceptionReasonParam = "exceptionReason"; public static final String exceptionResolvedParam = "exceptionResolved"; @@ -117,7 +131,9 @@ public final class ClientKycApiConstants { panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam, panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam, aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam, - aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam)); + aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam, + isPepParam, sanctionsMatchParam, adverseMediaMatchParam, + declaredAnnualIncomeParam, declaredAnnualTurnoverParam)); public static final Set UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, @@ -133,7 +149,9 @@ public final class ClientKycApiConstants { panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam, panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam, aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam, - aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam)); + aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam, + isPepParam, sanctionsMatchParam, adverseMediaMatchParam, + declaredAnnualIncomeParam, declaredAnnualTurnoverParam)); /** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */ public static final Set VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java index 8359f4938..55dece1d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java @@ -122,6 +122,13 @@ public final class ClientKycData { private final String form15Type; private final String form15Fy; + // ── Risk profile ───────────────────────────────────────────────────────── + private final Boolean isPep; + private final Boolean sanctionsMatch; + private final Boolean adverseMediaMatch; + private final java.math.BigDecimal declaredAnnualIncome; + private final java.math.BigDecimal declaredAnnualTurnover; + // ── CKYCRR ──────────────────────────────────────────────────────────────── private final String ckycNumber; private final LocalDate ckycUploadedOn; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java new file mode 100644 index 000000000..3f348d999 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.clientbehaviourwindow; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService; +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; + +@Configuration +public class ClientBehaviourWindowJobConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private ClientBehaviourWindowService clientBehaviourWindowService; + + @Bean + public ClientBehaviourWindowTasklet clientBehaviourWindowTasklet() { + return new ClientBehaviourWindowTasklet(clientBehaviourWindowService); + } + + @Bean + protected Step clientBehaviourWindowStep() { + return new StepBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository) + .tasklet(clientBehaviourWindowTasklet(), transactionManager).build(); + } + + @Bean + public Job clientBehaviourWindowJob() { + return new JobBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository).start(clientBehaviourWindowStep()) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java new file mode 100644 index 000000000..3d2c3e195 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.jobs.clientbehaviourwindow; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService; +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; + +/** + * Recomputes every customer's behaviour window and the rating each one suggests. + * + *

+ * The job only ever writes a suggestion. Moving a rating remains a decision an officer makes and a checker approves, + * which is what a re-rating is: a judgement about a customer, recorded by someone who is accountable for it. + */ +@RequiredArgsConstructor +public class ClientBehaviourWindowTasklet implements Tasklet { + + private final ClientBehaviourWindowService service; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + service.computeForAllClients(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java new file mode 100644 index 000000000..b5131bde8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Serves the most recent behaviour window for each customer, with the customer resolved. + * + *

+ * Only the latest window per customer: the table keeps each night's computation, and a review screen showing a + * customer once per night would be unreadable within a week. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClientBehaviourWindowReadPlatformService { + + private static final String SQL = """ + SELECT w.client_id, c.display_name AS client_name, c.account_no, o.name AS office_name, + w.window_from, w.window_to, w.txn_count, w.distinct_months, w.credit_total, w.debit_total, w.largest_txn, + w.cash_txn_count, w.cash_total, w.large_txn_count, w.declared_annual_income, w.income_ratio, + w.alert_count, w.str_case_count, w.risk_category_at_review, + w.suggested_direction, w.suggested_category, w.suggestion_reason + FROM m_client_behaviour_window w + JOIN m_client c ON c.id = w.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + -- The latest window per customer. DISTINCT ON keeps the first row of each client group, and the ORDER BY + -- is what makes that the newest one. + WHERE w.id IN (SELECT DISTINCT ON (client_id) id FROM m_client_behaviour_window + ORDER BY client_id, window_to DESC, computed_on DESC, id DESC) + -- A window describes the rating the customer held when the job ran. Once an officer moves that rating + -- the window is describing a customer who no longer exists, and serving it would have the screen go on + -- asking for a change that has already been applied and approved. Suppressed until tonight's run + -- recomputes it against the rating now on file. + AND (w.risk_category_at_review IS NULL OR k.risk_category IS NULL + OR w.risk_category_at_review = k.risk_category) + ORDER BY CASE w.suggested_direction WHEN 'UP' THEN 0 WHEN 'DOWN' THEN 1 ELSE 2 END, + c.display_name + """; + + private final JdbcTemplate jdbcTemplate; + + public List retrieveLatestPerClient() { + return jdbcTemplate.query(SQL, (rs, rowNum) -> new ClientBehaviourWindowData(rs.getLong("client_id"), + rs.getString("client_name"), rs.getString("account_no"), rs.getString("office_name"), + rs.getDate("window_from") == null ? null : rs.getDate("window_from").toLocalDate(), + rs.getDate("window_to") == null ? null : rs.getDate("window_to").toLocalDate(), + (Integer) rs.getObject("txn_count"), (Integer) rs.getObject("distinct_months"), rs.getBigDecimal("credit_total"), + rs.getBigDecimal("debit_total"), + rs.getBigDecimal("largest_txn"), (Integer) rs.getObject("cash_txn_count"), rs.getBigDecimal("cash_total"), + (Integer) rs.getObject("large_txn_count"), rs.getBigDecimal("declared_annual_income"), + rs.getBigDecimal("income_ratio"), (Integer) rs.getObject("alert_count"), + (Integer) rs.getObject("str_case_count"), (Integer) rs.getObject("risk_category_at_review"), + rs.getString("suggested_direction"), (Integer) rs.getObject("suggested_category"), + rs.getString("suggestion_reason"))); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java new file mode 100644 index 000000000..e817e0fe3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java @@ -0,0 +1,247 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindow; +import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindowRepository; +import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.BehaviourFacts; +import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.Recommendation; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Computes what each customer's money did over a period, and asks the policy what that means for their rating. + * + *

+ * Run nightly. The figures are aggregated in the database rather than by loading each customer's transactions into + * memory: a book of any size would otherwise read every transaction row it has ever written, once per customer, every + * night. + * + *

+ * Two windows are computed per customer, not one. An upgrade asks what the customer is doing now and reads a short + * window; a downgrade asks whether a year of conduct supports removing a rating. Computing only the longer window + * would delay every upgrade by months, and only the shorter would lower ratings on a quiet fortnight. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ClientBehaviourWindowService { + + /** + * One customer's aggregated activity for one window. + * + *

+ * Every count and total is computed in SQL; nothing here re-reads transactions. + */ + private record Aggregate(Long clientId, Integer currentCategory, int txnCount, int distinctMonths, BigDecimal creditTotal, + BigDecimal debitTotal, BigDecimal largestTxn, int cashTxnCount, BigDecimal cashTotal, int largeTxnCount, + int ctrTxnCount, int mediumAlertCount, int highAlertCount, int openStrCount, int strInBlockWindow, boolean pep, + boolean sanctionsMatch, boolean adverseMedia, boolean graveRuleFired, boolean kycOverdue, + Integer daysInCurrentCategory, BigDecimal declaredIncome, BigDecimal declaredTurnover) {} + + /** + * Everything the policy tests, for every active customer, in one pass. + * + *

+ * Each subquery is scoped to the same window so the figures describe one period. Alerts are counted by their worst + * matched severity rather than by the rule the alert was raised under, because a statutory CTR outranks a + * behavioural rule when the governing rule is picked - counting the governing rule would lose the high-severity + * finding underneath it. + */ + private static final String AGGREGATE_SQL = """ + SELECT c.id AS client_id, + k.risk_category AS current_category, + COALESCE(t.txn_count, 0) AS txn_count, + COALESCE(t.distinct_months, 0) AS distinct_months, + COALESCE(t.credit_total, 0) AS credit_total, + COALESCE(t.debit_total, 0) AS debit_total, + COALESCE(t.largest_txn, 0) AS largest_txn, + COALESCE(t.cash_txn_count, 0) AS cash_txn_count, + COALESCE(t.cash_total, 0) AS cash_total, + COALESCE(t.large_txn_count, 0) AS large_txn_count, + COALESCE(t.ctr_txn_count, 0) AS ctr_txn_count, + COALESCE(a.medium_alerts, 0) AS medium_alerts, + COALESCE(a.high_alerts, 0) AS high_alerts, + COALESCE(a.grave_rule, false) AS grave_rule, + COALESCE(s.open_str, 0) AS open_str, + COALESCE(s.str_in_block_window, 0) AS str_in_block_window, + COALESCE(k.is_pep, false) AS pep, + COALESCE(k.sanctions_match, false) AS sanctions_match, + COALESCE(k.adverse_media_match, false) AS adverse_media, + CASE WHEN k.expiry_date IS NOT NULL AND k.expiry_date < ? THEN true ELSE false END AS kyc_overdue, + h.days_in_category, + k.declared_annual_income, + k.declared_annual_turnover + FROM m_client c + LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + -- Savings activity in the window, by client. + LEFT JOIN ( + SELECT sa.client_id, + count(*) AS txn_count, + count(DISTINCT to_char(tx.transaction_date, 'YYYY-MM')) AS distinct_months, + sum(CASE WHEN tx.transaction_type_enum = 1 THEN tx.amount ELSE 0 END) AS credit_total, + sum(CASE WHEN tx.transaction_type_enum = 2 THEN tx.amount ELSE 0 END) AS debit_total, + max(tx.amount) AS largest_txn, + count(*) FILTER (WHERE pt.is_cash_payment) AS cash_txn_count, + COALESCE(sum(tx.amount) FILTER (WHERE pt.is_cash_payment), 0) AS cash_total, + count(*) FILTER (WHERE tx.amount >= ?) AS large_txn_count, + count(*) FILTER (WHERE pt.is_cash_payment AND tx.amount >= ?) AS ctr_txn_count + FROM m_savings_account_transaction tx + JOIN m_savings_account sa ON sa.id = tx.savings_account_id + LEFT JOIN m_payment_detail pd ON pd.id = tx.payment_detail_id + LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE tx.is_reversed = false + AND tx.transaction_type_enum IN (1, 2) + AND tx.transaction_date BETWEEN ? AND ? + GROUP BY sa.client_id + ) t ON t.client_id = c.id + -- Alerts in the window, graded by the worst rule each matched. + LEFT JOIN ( + SELECT al.client_id, + count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 2) AS medium_alerts, + count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 3) AS high_alerts, + bool_or(ar.rule_code IS NOT NULL) AS grave_rule + FROM m_aml_alert al + LEFT JOIN m_aml_alert_rule ar + ON ar.alert_id = al.id + AND (ar.rule_code LIKE 'STRUCTUR%' OR ar.rule_code LIKE 'PEP%' + OR ar.rule_code LIKE 'SANCTION%' OR ar.rule_code LIKE 'ADVERSE_MEDIA%' + OR ar.rule_code LIKE 'HIGH_RISK_COUNTRY%' OR ar.rule_code LIKE 'WILFUL_DEFAULTER%') + WHERE al.raised_on >= ? AND al.raised_on < ? + GROUP BY al.client_id + ) a ON a.client_id = c.id + -- STR cases: open ones hold a rating at High; recent concluded ones block a downgrade. + LEFT JOIN ( + SELECT sc.client_id, + count(*) FILTER (WHERE sc.status = 'OPEN') AS open_str, + count(*) FILTER (WHERE sc.created_on_utc >= ?) AS str_in_block_window + FROM m_aml_str_case sc + GROUP BY sc.client_id + ) s ON s.client_id = c.id + -- How long the customer has held the rating they are on, from the last recorded change. + LEFT JOIN ( + SELECT client_id, (? - max(changed_on)::date) AS days_in_category + FROM m_client_risk_category_history + GROUP BY client_id + ) h ON h.client_id = c.id + WHERE c.status_enum = 300 + """; + + private final JdbcTemplate jdbcTemplate; + private final ClientBehaviourWindowRepository windowRepository; + private final RiskMovementPolicyService policyService; + + /** ₹10,00,000 - the CTR line, and the figure "large transaction" means here. */ + private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000"); + + /** + * Computes tonight's windows for every active customer. + * + *

+ * The upgrade window is written for every customer; the downgrade window only for those rated Medium or High, + * since there is nothing below Low to move to. The suggestion is taken from whichever window the policy acted on, + * so the figures shown beside a recommendation are the ones it was drawn from. + */ + @Transactional + public int computeForAllClients() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + windowRepository.deleteByWindowTo(today); + + final int upgradeDays = policyService.windowDaysFor("UPGRADE_WINDOW_DAYS", 90); + final int highToMediumDays = policyService.windowDaysFor("HIGH_TO_MEDIUM_WINDOW_DAYS", 365); + final int mediumToLowDays = policyService.windowDaysFor("MEDIUM_TO_LOW_WINDOW_DAYS", 270); + final int strBlockMonths = policyService.windowDaysFor("STR_BLOCK_MONTHS", 24); + + final List shortWindow = aggregate(today.minusDays(upgradeDays), today, strBlockMonths); + + int written = 0; + for (final Aggregate shortAgg : shortWindow) { + final Integer category = shortAgg.currentCategory(); + // A downgrade is judged over the window that band requires; Low has nothing below it. + final int longDays = category != null && category == RiskMovementPolicyService.HIGH ? highToMediumDays : mediumToLowDays; + final Aggregate longAgg = category != null && category > RiskMovementPolicyService.LOW + ? one(today.minusDays(longDays), today, strBlockMonths, shortAgg.clientId()) + : null; + + final Recommendation shortResult = policyService.evaluate(factsOf(shortAgg)); + // The short window decides upgrades and the standing facts; anything else is a question for the long one. + final boolean actedOnShort = RiskMovementPolicyService.UP.equals(shortResult.direction()) || longAgg == null; + final Aggregate acted = actedOnShort ? shortAgg : longAgg; + final Recommendation result = actedOnShort ? shortResult : policyService.evaluate(factsOf(longAgg)); + + final LocalDate from = actedOnShort ? today.minusDays(upgradeDays) : today.minusDays(longDays); + final ClientBehaviourWindow window = ClientBehaviourWindow.of(acted.clientId(), from, today); + window.recordActivity(acted.txnCount(), acted.creditTotal(), acted.debitTotal(), acted.largestTxn(), + acted.cashTxnCount(), acted.cashTotal(), acted.largeTxnCount()); + window.recordDistinctMonths(acted.distinctMonths()); + window.recordMonitoring(acted.mediumAlertCount(), acted.openStrCount(), acted.currentCategory()); + window.recordDeclared(acted.declaredIncome(), acted.declaredTurnover(), + acted.creditTotal() == null ? null : acted.creditTotal().add(acted.debitTotal())); + window.recordSuggestion(result.direction(), result.suggestedCategory(), result.reason()); + windowRepository.save(window); + written++; + } + log.info("Client behaviour windows computed for {} clients as at {}", written, today); + return written; + } + + private BehaviourFacts factsOf(final Aggregate a) { + final BigDecimal periodTotal = a.creditTotal().add(a.debitTotal()); + return new BehaviourFacts(a.currentCategory(), a.txnCount(), a.distinctMonths(), a.cashTotal(), periodTotal, + a.largestTxn(), a.ctrTxnCount(), a.mediumAlertCount(), a.highAlertCount(), a.openStrCount(), + a.strInBlockWindow(), a.pep(), a.sanctionsMatch(), a.adverseMedia(), a.graveRuleFired(), a.kycOverdue(), + a.daysInCurrentCategory()); + } + + private List aggregate(final LocalDate from, final LocalDate to, final int strBlockMonths) { + return query(AGGREGATE_SQL, from, to, strBlockMonths, null); + } + + private Aggregate one(final LocalDate from, final LocalDate to, final int strBlockMonths, final Long clientId) { + final List rows = query(AGGREGATE_SQL + " AND c.id = ? ", from, to, strBlockMonths, clientId); + return rows.isEmpty() ? null : rows.get(0); + } + + private List query(final String sql, final LocalDate from, final LocalDate to, final int strBlockMonths, + final Long clientId) { + final List args = new ArrayList<>(List.of(to, CTR_THRESHOLD, CTR_THRESHOLD, from, to, from.atStartOfDay(), + to.plusDays(1).atStartOfDay(), to.minusMonths(strBlockMonths).atStartOfDay(), to)); + if (clientId != null) { + args.add(clientId); + } + return jdbcTemplate.query(sql, (rs, rowNum) -> new Aggregate(rs.getLong("client_id"), + rs.getObject("current_category") == null ? null : rs.getInt("current_category"), rs.getInt("txn_count"), + rs.getInt("distinct_months"), rs.getBigDecimal("credit_total"), rs.getBigDecimal("debit_total"), + rs.getBigDecimal("largest_txn"), rs.getInt("cash_txn_count"), rs.getBigDecimal("cash_total"), + rs.getInt("large_txn_count"), rs.getInt("ctr_txn_count"), rs.getInt("medium_alerts"), rs.getInt("high_alerts"), + rs.getInt("open_str"), rs.getInt("str_in_block_window"), rs.getBoolean("pep"), rs.getBoolean("sanctions_match"), + rs.getBoolean("adverse_media"), rs.getBoolean("grave_rule"), rs.getBoolean("kyc_overdue"), + rs.getObject("days_in_category") == null ? null : rs.getInt("days_in_category"), + rs.getBigDecimal("declared_annual_income"), rs.getBigDecimal("declared_annual_turnover")), + args.toArray()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index 41ece59e2..1e8638804 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -132,6 +132,11 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .form15Applicable(kyc.getForm15Applicable()) .form15Type(kyc.getForm15Type()) .form15Fy(kyc.getForm15Fy()) + .isPep(kyc.isPep()) + .sanctionsMatch(kyc.isSanctionsMatch()) + .adverseMediaMatch(kyc.isAdverseMediaMatch()) + .declaredAnnualIncome(kyc.getDeclaredAnnualIncome()) + .declaredAnnualTurnover(kyc.getDeclaredAnnualTurnover()) .ckycNumber(kyc.getCkycNumber()) .ckycUploadedOn(kyc.getCkycUploadedOn()) .ckycBatchId(kyc.getCkycBatchId()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java new file mode 100644 index 000000000..150a5c136 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java @@ -0,0 +1,276 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.domain.RiskPolicyParameter; +import org.apache.fineract.portfolio.client.domain.RiskPolicyParameterRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Decides where a customer's rating should sit, given a period of their behaviour. + * + *

+ * Two principles shape every rule here, and they are not symmetric. + * + *

+ * A rating never moves on one transaction. Not up and not down. A single shop purchase does not argue for + * lowering a High rating, and a single large deposit does not make an ordinary customer suspicious - it may be a + * property sale. Both are alerts worth an officer's attention, and neither is a pattern. The minimum counts below are + * the same test the transaction rules already apply to themselves. + * + *

+ * Suspicion moves faster than confidence. A concern is about what the customer is doing now, so upgrades read a + * short window. Removing a High rating is a judgement that an earlier concern no longer holds, which needs a year of + * evidence spread across months rather than a quiet fortnight. The one exception is the set of facts that are not + * about behaviour at all - an STR, a sanctions hit, a PEP determination - which move a rating immediately because no + * amount of ordinary transacting answers them. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RiskMovementPolicyService { + + public static final int LOW = 1; + public static final int MEDIUM = 2; + public static final int HIGH = 3; + + public static final String UP = "UP"; + public static final String DOWN = "DOWN"; + public static final String HOLD = "HOLD"; + + private final RiskPolicyParameterRepository parameterRepository; + + /** One customer's period, as the policy needs to see it. */ + public record BehaviourFacts(Integer currentCategory, int txnCount, int distinctMonths, BigDecimal cashTotal, + BigDecimal periodTotal, BigDecimal largestTxn, int ctrTxnCount, int mediumAlertCount, int highAlertCount, + int openStrCount, int strCountInBlockWindow, boolean pep, boolean sanctionsMatch, boolean adverseMedia, + boolean graveRuleFired, boolean kycOverdue, Integer daysInCurrentCategory) {} + + /** What the policy concluded, and the sentence an officer reads. */ + public record Recommendation(String direction, Integer suggestedCategory, String reason) {} + + public Recommendation evaluate(final BehaviourFacts f) { + final Map p = parameters(); + + // ── Facts that are not about behaviour ────────────────────────────────────────────────── + // These move a rating on their own. No quantity of ordinary transacting answers a sanctions match, and an STR + // is the bank's own statement that it found the customer's activity suspicious. + if (f.sanctionsMatch()) { + return high(f, "Sanctions or watch-list match — rating held at High irrespective of activity."); + } + if (f.pep()) { + return high(f, "Politically exposed person — High is the standing rating, not a conclusion about this period."); + } + if (f.adverseMedia()) { + return high(f, "Adverse media or regulatory action on record — rating held at High."); + } + if (f.openStrCount() > 0) { + return high(f, "An STR case is open — rating held at High until the Principal Officer concludes it."); + } + + // ── Upgrades ──────────────────────────────────────────────────────────────────────────── + // Read before the downgrade tests: a customer whose activity has grown is not a candidate for lowering, and + // saying so is more useful than "insufficient activity to lower". + final Recommendation up = considerUpgrade(f, p); + if (up != null) { + return up; + } + + // ── Downgrades ────────────────────────────────────────────────────────────────────────── + return considerDowngrade(f, p); + } + + private Recommendation considerUpgrade(final BehaviourFacts f, final Map p) { + final int current = f.currentCategory() == null ? LOW : f.currentCategory(); + if (current >= HIGH) { + return null; + } + // A pattern, even to raise a rating. One large transaction is an alert for an officer to look at, not grounds + // for re-rating the customer who made it. + if (f.txnCount() < intOf(p, "MIN_TXN_FOR_UPGRADE", 5)) { + return null; + } + + if (current == MEDIUM) { + if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) { + return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount())); + } + if (f.graveRuleFired()) { + return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount."); + } + if (atLeast(f.cashTotal(), p.get("MED_TO_HIGH_CASH_TOTAL"))) { + return raise(HIGH, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal()))); + } + return null; + } + + // Low, or unrated and treated as Low. + if (f.graveRuleFired()) { + return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount."); + } + if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) { + return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount())); + } + if (atLeast(f.cashTotal(), p.get("LOW_TO_MED_CASH_TOTAL"))) { + return raise(MEDIUM, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal()))); + } + if (f.ctrTxnCount() >= intOf(p, "LOW_TO_MED_CTR_COUNT", 3)) { + return raise(MEDIUM, "%d CTR-reportable transactions in the review window.".formatted(f.ctrTxnCount())); + } + if (f.mediumAlertCount() >= intOf(p, "LOW_TO_MED_ALERT_COUNT", 3)) { + return raise(MEDIUM, "%d alerts of medium severity or worse in the review window.".formatted(f.mediumAlertCount())); + } + return null; + } + + private Recommendation considerDowngrade(final BehaviourFacts f, final Map p) { + if (f.currentCategory() == null) { + // An unrated customer who is transacting is a gap in the book, not a quiet account. Saying only "no rating + // on record" leaves an officer nothing to act on, so the size of what went through is named: an unrated + // customer who has moved reportable sums needs rating now, not at the next periodic review. + final boolean reportable = f.ctrTxnCount() > 0 || atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL")); + return new Recommendation(HOLD, null, reportable + ? "No rating on record, and %s passed through the account in the window — the customer must be rated before this activity can be judged." + .formatted(inr(f.periodTotal())) + : "No rating on record — the customer must be rated before behaviour can be compared against it."); + } + final int current = f.currentCategory(); + if (current <= LOW) { + return hold(f, "Already at the lowest rating."); + } + + // Blocks. Each is a reason the period's figures cannot speak for the customer, so they are tested before the + // figures are. + if (f.strCountInBlockWindow() > 0) { + return hold(f, "An STR was filed within the last %d months — rating held.".formatted(intOf(p, "STR_BLOCK_MONTHS", 24))); + } + if (f.graveRuleFired()) { + return hold(f, "Structuring or an equivalent pattern on record — small amounts are not a reason to lower this."); + } + if (f.kycOverdue()) { + return hold(f, "KYC is overdue or documents have expired — the rating cannot be lowered on an unverified record."); + } + + final boolean toMedium = current == HIGH; + final int minTxn = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_TXN" : "MEDIUM_TO_LOW_MIN_TXN", toMedium ? 20 : 12); + final int minMonths = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_MONTHS" : "MEDIUM_TO_LOW_MIN_MONTHS", toMedium ? 6 : 5); + final int windowDays = intOf(p, toMedium ? "HIGH_TO_MEDIUM_WINDOW_DAYS" : "MEDIUM_TO_LOW_WINDOW_DAYS", toMedium ? 365 : 270); + + if (f.txnCount() < minTxn) { + return hold(f, "Only %d transactions in the window; %d are needed to lower a %s rating.".formatted(f.txnCount(), minTxn, + label(current))); + } + // Spread as well as count: twenty transactions in one week is not a year of steady conduct. + if (f.distinctMonths() < minMonths) { + return hold(f, "Activity falls in %d months; %d separate months are needed.".formatted(f.distinctMonths(), minMonths)); + } + if (f.daysInCurrentCategory() != null && f.daysInCurrentCategory() < windowDays) { + return hold(f, "Only %d days at %s; %d are needed before the rating is reconsidered." + .formatted(f.daysInCurrentCategory(), label(current), windowDays)); + } + if (f.highAlertCount() > 0) { + return hold(f, "%d high-severity alert(s) in the window — the period is not clean.".formatted(f.highAlertCount())); + } + + // Size. Volume alone holds a rating up: a customer moving large sums is not low-risk merely for doing it + // without tripping a rule. + if (atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"))) { + return hold(f, "Turnover of %s in the window holds the rating where it is.".formatted(inr(f.periodTotal()))); + } + if (!toMedium) { + // Medium to Low is the stricter of the two: Low is the least-monitored band. + if (!below(f.largestTxn(), p.get("SMALL_TXN_MAX")) || !below(f.periodTotal(), p.get("SMALL_TOTAL_MAX"))) { + return hold(f, "Activity is larger than Low-rated behaviour — largest %s, %s in total." + .formatted(inr(f.largestTxn()), inr(f.periodTotal()))); + } + } + + // One band at a time. High to Low in a single step would skip the year at Medium that is the evidence for it. + final int suggested = current - 1; + return new Recommendation(DOWN, suggested, + "%d transactions across %d months, %s in total, no high-severity alerts — %s is proportionate." + .formatted(f.txnCount(), f.distinctMonths(), inr(f.periodTotal()), label(suggested))); + } + + private Recommendation high(final BehaviourFacts f, final String reason) { + final boolean alreadyHigh = f.currentCategory() != null && f.currentCategory() == HIGH; + return new Recommendation(alreadyHigh ? HOLD : UP, HIGH, reason); + } + + private Recommendation raise(final int category, final String reason) { + return new Recommendation(UP, category, reason); + } + + private Recommendation hold(final BehaviourFacts f, final String reason) { + return new Recommendation(HOLD, f.currentCategory(), reason); + } + + private Map parameters() { + return parameterRepository.findAll().stream() + .collect(Collectors.toMap(RiskPolicyParameter::getParameterKey, RiskPolicyParameter::getParameterValue, (a, b) -> a)); + } + + /** + * A parameter's value, or the seeded default. + * + *

+ * The fallback is not decoration: a tenant whose parameter table has not been seeded must still apply the policy, + * rather than reading a missing row as zero and lowering every rating that asked for "at least 0 transactions". + */ + private int intOf(final Map p, final String key, final int fallback) { + final BigDecimal value = p.get(key); + return value == null ? fallback : value.intValue(); + } + + private boolean atLeast(final BigDecimal value, final BigDecimal threshold) { + return value != null && threshold != null && value.compareTo(threshold) >= 0; + } + + private boolean below(final BigDecimal value, final BigDecimal threshold) { + return value != null && threshold != null && value.compareTo(threshold) < 0; + } + + private String label(final int category) { + return switch (category) { + case LOW -> "Low"; + case MEDIUM -> "Medium"; + case HIGH -> "High"; + default -> "Unrated"; + }; + } + + private String inr(final BigDecimal amount) { + return amount == null ? "nil" : "INR " + amount.setScale(0, java.math.RoundingMode.HALF_UP).toPlainString(); + } + + /** Exposed so the job can name the windows it must compute without repeating the keys. */ + public int windowDaysFor(final String key, final int fallback) { + return intOf(parameters(), key, fallback); + } + + public List downgradeWindowKeys() { + return List.of("HIGH_TO_MEDIUM_WINDOW_DAYS", "MEDIUM_TO_LOW_WINDOW_DAYS"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java new file mode 100644 index 000000000..3ad4474df --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java @@ -0,0 +1,147 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The Board of Resolution register for share capital: every share account with the resolution that + * authorised it. + * + *

+ * A co-operative allots share capital only under a board resolution, and the register is what an + * auditor or the Registrar asks for - one line per member showing the resolution number against the + * shares it authorised. Served as a single query rather than assembled on the client, which had to + * fetch each account separately and stopped at two hundred of them. + * + *

+ * Read-only; nothing here writes. + */ +@Path("/v1/shareaccounts/bor-register") +@Component +@Tag(name = "Share BOR Register", + description = "Share accounts with the board resolution that authorised them, for the BOR register.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareBorRegisterApiResource { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + /** + * @param status + * m_share_account.status_enum, to show only active or only closed members; omit for + * every account whatever its status, which is what a register is + * @param officeId + * a single branch, for a branch-scoped login + */ + @GET + @Operation(summary = "Share accounts with their board resolution", + description = "One row per share account: member, branch, product, shares held, and the resolution number and date that authorised the allotment.") + public Response retrieveRegister(@QueryParam("status") final Integer status, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser(); + + // Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so + // queryForList would key the maps "accountno" and "resolutionnumber" - which serialise + // straight into the JSON and leave the client reading accountNo as undefined. + // + // Every date goes through to_char. Gson serialises a java.sql.Date with its default date + // format, which produced "Sept 14, 2026" - a locale-dependent string the client cannot + // parse, sort or compare, and which its ISO reader truncated to "Sept 14, 2". A date column + // is sent as an ISO string so it reads the same wherever it lands. + // + // resolution_date is selected but is null on every row today: the column exists and the + // frontend sends a value, but ShareAccount carries no @Column mapping for it, so the value + // is dropped on save. The meeting date that IS written lives on the purchase transaction, + // so it is read from there and the account's own date is used only as a fallback. + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.id AS "officeId", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + sa.total_approved_shares AS "approvedShares", + sa.total_pending_shares AS "pendingShares", + sp.unit_price AS "unitPrice", + COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + sa.resolution_number AS "resolutionNumber", + to_char(COALESCE(sa.resolution_date, txn.first_meeting_date), 'YYYY-MM-DD') AS "resolutionDate", + txn.tranche_count AS "trancheCount", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + MIN(t.meeting_date) AS first_meeting_date, + COUNT(*) AS tranche_count + FROM m_share_account_transactions t + WHERE t.is_active = true AND t.type_enum = 500 + GROUP BY t.account_id + ) txn ON txn.account_id = sa.id + WHERE 1 = 1 + """); + + final List args = new ArrayList<>(); + if (status != null) { + sql.append(" AND sa.status_enum = ? "); + args.add(status); + } + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + // Resolution first: the register is read by resolution, and an account with no resolution + // recorded is the thing an auditor is looking for, so those sort to the top rather than + // being buried at the end of the list. + sql.append(" ORDER BY sa.resolution_number NULLS FIRST, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return Response.ok(gson.toJson(rows)).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index 3282fd5a9..bf3465113 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -109,6 +109,18 @@ public class ShareAccount extends AbstractPersistableCustom { @Column(name = "resolution_number", nullable = false) private String resolutionNumber; + /** + * Date of the board meeting that passed the resolution above. + * + *

+ * The column has existed since 0470 and the frontend has been sending a value all along, but + * nothing mapped it - so every allotment was saved with the resolution number and without the + * date it was passed on, and the BOR register had nothing to show the Registrar. Nullable + * because the accounts already on file carry no date and cannot be given one retrospectively. + */ + @Column(name = "resolution_date") + private LocalDate resolutionDate; + @Column(name = "account_no", length = 20, unique = true, nullable = false) private String accountNumber; @@ -260,6 +272,19 @@ public class ShareAccount extends AbstractPersistableCustom { return toReturn; } + public LocalDate getResolutionDate() { + return this.resolutionDate; + } + + public boolean setResolutionDate(final LocalDate resolutionDate) { + boolean toReturn = false; + if (this.resolutionDate == null || !this.resolutionDate.equals(resolutionDate)) { + this.resolutionDate = resolutionDate; + toReturn = true; + } + return toReturn; + } + public boolean setSavingsAccount(final SavingsAccount savingsAccount) { boolean returnValue = false; if (!this.savingsAccount.getId().equals(savingsAccount.getId())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 145123138..ff3afb640 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -238,6 +238,14 @@ public class ShareAccountDataSerializer { minimumActivePeriod, minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy, rejectedDate, activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate); + // Set after construction rather than threaded through the constructor: it already takes + // twenty-eight arguments, and every caller would have to change to carry one optional date. + final LocalDate resolutionDate = this.fromApiJsonHelper + .extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element); + if (resolutionDate != null) { + account.setResolutionDate(resolutionDate); + } + for (ShareAccountTransaction pur : sharesPurchased) { pur.setShareAccount(account); } @@ -326,6 +334,14 @@ public class ShareAccountDataSerializer { } } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.resolutiondate_paramname, element)) { + final LocalDate resolutionDate = this.fromApiJsonHelper + .extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element); + if (account.setResolutionDate(resolutionDate)) { + actualChanges.put(ShareAccountApiConstants.resolutiondate_paramname, resolutionDate); + } + } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) { final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element); if (account.setLinkedLoanId(linkedLoanId)) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 7c72ab993..464e245d9 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -85,4 +85,21 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml new file mode 100644 index 000000000..1404542fc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml new file mode 100644 index 000000000..4ce02a378 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + The other rules the same transaction matched. Kept so nothing is lost when the alert is + reduced to one row - the analyst can still see that a deposit was both reportable and on + a new account, and the case file shows every rule that fired. + + + + + + + + + + + + Read on every evaluation to find an alert already raised for this transaction, so a + re-run does not raise a second one. + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml new file mode 100644 index 000000000..19dbf8177 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The approval queue reads by status; the report reads by client. + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml new file mode 100644 index 000000000..6a00ea773 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + UPDATE m_client_risk_rule r + SET coverage_status = 'AUTOMATED', + coverage_note = 'Evaluated by the AML engine on every matching transaction.' + WHERE rule_kind = 'AML' + AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id); + + + + + + + UPDATE m_client_risk_rule + SET coverage_status = 'NOT_APPLICABLE', + coverage_note = 'No trade finance or import/export business is transacted on this CBS, so this scenario cannot arise.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE 'Trade Based Money Laundering%' OR rule_name LIKE '%Overseas Direct Investment%'); + + UPDATE m_client_risk_rule + SET coverage_status = 'NOT_APPLICABLE', + coverage_note = 'No cross-border remittance or foreign exchange business is transacted on this CBS.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE 'Afghan Drug Business%' + OR rule_name LIKE '%cross border%' OR rule_name LIKE '%Cross border%' + OR rule_name LIKE '%foreign remittance%' OR rule_name LIKE '%foreign inward%' + OR rule_name LIKE '%outward remittance%' OR rule_name LIKE '%Outward foreign%' + OR rule_name LIKE '%another country%' OR rule_name LIKE '%tax havens%'); + + UPDATE m_client_risk_rule + SET coverage_status = 'NOT_APPLICABLE', + coverage_note = 'No credit card is issued on this CBS.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE 'Credit Card Monitoring%' OR rule_name LIKE '%credit card%'); + + UPDATE m_client_risk_rule + SET coverage_status = 'NOT_APPLICABLE', + coverage_note = 'No safe deposit locker module exists on this CBS.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND rule_name LIKE '%Locker%'; + + + + + + + UPDATE m_client_risk_rule + SET coverage_status = 'MANUAL_REVIEW', + coverage_note = 'Turns on an officer''s judgement at the counter or during periodic review; carried on the AML review checklist.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE 'Customer Due Diligence%' + OR rule_name LIKE 'Name Screening%' + OR rule_name LIKE '%Shell Companies%' + OR rule_name LIKE '%adverse media%' OR rule_name LIKE '%Adverse media%' + OR rule_name LIKE '%could not explain%' + OR rule_name LIKE '%does not reside%' + OR rule_name LIKE '%Complaint received%' + OR rule_name LIKE '%Mutilated%' + OR rule_name LIKE 'FICN%' + OR rule_name LIKE 'Terrorist Financing%' + OR rule_name LIKE 'Non Customer Monitoring%' + OR rule_name LIKE 'Additional RFIs Rule%' + OR rule_name LIKE '%Risk Categorization%'); + + + + + + + UPDATE m_client_risk_rule + SET coverage_status = 'PLANNED', + coverage_note = 'Needs counterparty identity on the transaction context - not yet captured.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE '%Many to One%' OR rule_name LIKE '%One to Many%' + OR rule_name LIKE '%common phone%' OR rule_name LIKE '%joint holder%' + OR rule_name LIKE '%customers working together%' + OR rule_name LIKE '%Multiple accounts under the same name%' + OR rule_name LIKE '%unrelated parties%' + OR rule_name LIKE '%single Indian party%' + OR rule_name LIKE '%same remitter%' + OR rule_name LIKE '%several accounts%'); + + UPDATE m_client_risk_rule + SET coverage_status = 'PLANNED', + coverage_note = 'Needs the customer''s declared turnover compared against actual activity - not yet computed.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE '%inconsistent with Profile%' OR rule_name LIKE '%inconsistent with client profile%' + OR rule_name LIKE 'Sudden %' OR rule_name LIKE '%surge in activity%' + OR rule_name LIKE '%High Net worth%'); + + UPDATE m_client_risk_rule + SET coverage_status = 'PLANNED', + coverage_note = 'Needs the transaction channel (ATM, UPI, NEFT, RTGS, POS) on the context - not yet captured.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL + AND (rule_name LIKE '%ATM%' OR rule_name LIKE '%card usage%' + OR rule_name LIKE '%Non Home Branch%' OR rule_name LIKE '%multiple branches%'); + + + + + + + UPDATE m_client_risk_rule + SET coverage_status = 'AUTOMATED', + coverage_note = 'Evaluated by the AML engine on every matching transaction.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml new file mode 100644 index 000000000..ef60478fc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml @@ -0,0 +1,387 @@ + + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_DEPOSIT_DAY' + + + -- Helper: each INSERT links back to its catalog scenario by name, so the rule and the scenario it + -- implements stay connected and the register can show which is which. + + -- ── Daily cash, by direction ──────────────────────────────────────────────────────────────── + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_CASH_DEPOSIT_DAY', 'High value cash deposits in a day', 'SAVINGS', 1000000, 'THRESHOLD', + NULL, NULL, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High value cash deposits in a day' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_DAY', 'High value cash withdrawals in a day', 'SAVINGS', 1000000, 'THRESHOLD', + NULL, NULL, 'DEBIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High value cash withdrawals in a day' LIMIT 1; + + -- Large cash debit. Separate from LARGE_CASH_TXN_25L, which is direction-agnostic: this is the catalog's + -- own withdrawal-side scenario and carries the lower 269ST-informed figure. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'LARGE_CASH_TXN_DEBIT', 'Large cash transaction (Debit)', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, 'DEBIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Large cash transaction(Debit)' LIMIT 1; + + -- ── Monthly cash aggregates ───────────────────────────────────────────────────────────────── + -- STRUCTURING rather than THRESHOLD: the concern is a month's worth of deposits summing past the CTR + -- line, which is only a pattern if there were several of them. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_CASH_DEPOSIT_MONTH', 'High Value Cash Deposits in a month', 'SAVINGS', 1000000, 'STRUCTURING', + 30, 3, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Deposits in a month' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_MONTH', 'High Value Cash Withdrawals in a month', 'SAVINGS', 1000000, 'STRUCTURING', + 30, 3, 'DEBIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Withdrawals in a month' LIMIT 1; + + -- ── Non-cash, daily and monthly ───────────────────────────────────────────────────────────── + -- cash_only false: these are deliberately the transfer/cheque counterparts of the rules above, which is + -- why the catalog lists them separately. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_DAY', 'High Value Non-Cash Deposits in a day', 'ALL', 2000000, 'THRESHOLD', + NULL, NULL, 'CREDIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value Non- Cash Deposits in a day' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_DAY', 'High Value non-Cash Withdrawal in a day', 'ALL', 2000000, 'THRESHOLD', + NULL, NULL, 'DEBIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value non- Cash Withdrawal in a day' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_MONTH', 'High Value non-cash Deposits in a month', 'ALL', 2000000, 'STRUCTURING', + 30, 3, 'CREDIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value non-cash Deposits in a month' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_MONTH', 'High value non-cash withdrawals in a month', 'ALL', 2000000, 'STRUCTURING', + 30, 3, 'DEBIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High value non-cash withdrawals in a month' LIMIT 1; + + -- ── Credit entries and current accounts ───────────────────────────────────────────────────── + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'CREDIT_ENTRY_THRESHOLD', 'Threshold Limit in Credit entry', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, 'CREDIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Threshold Limit in Credit entry' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'CURRENT_ACCOUNT_HIGH_VALUE', 'High Value Transactions in current account', 'CURRENT', 2500000, 'THRESHOLD', + NULL, NULL, NULL, false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions in current account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'CURRENT_ACCOUNT_UNUSUAL_ACTIVITY', 'Unusually high activity in Current Account', 'CURRENT', 5000000, 'VELOCITY', + 30, 10, NULL, false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Unusually high activity in Current Account(CA)' LIMIT 1; + + -- ── New accounts ──────────────────────────────────────────────────────────────────────────── + -- NEW_ACCOUNT scope keeps these off long-standing customers; the engine reads the account's opening date + -- from the context rather than inferring it from transaction history. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NEW_ACCOUNT_HIGH_VALUE_TXN', 'High Value transactions in a new Account', 'ALL', 500000, 'THRESHOLD', + 30, NULL, NULL, false, 'NEW_ACCOUNT', id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value transactions in a new Account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NEW_ACCOUNT_HIGH_ACTIVITY', 'High activity in a new account', 'ALL', 200000, 'VELOCITY', + 30, 5, NULL, false, 'NEW_ACCOUNT', id, true + FROM m_client_risk_rule WHERE rule_name = 'High activity in a new account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NEW_ACCOUNT_TXN_COUNT_MONTHLY', 'Number of Transactions in newly opened Accounts, monthly', 'ALL', 100000, 'VELOCITY', + 30, 10, NULL, false, 'NEW_ACCOUNT', id, true + FROM m_client_risk_rule WHERE rule_name = 'Number of Transactions in newly opened Accounts, monthly' LIMIT 1; + + -- ── Subject-scoped: who the customer is ───────────────────────────────────────────────────── + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'REAL_ESTATE_ACCOUNT_MONITORING', 'Monitor accounts of parties in real estate', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Monitor accounts of parties%occupation related to Real Estate' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'REAL_ESTATE_CASH_HIGH_VALUE', 'High value cash transactions - real estate agents and dealers', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true + FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions pertaining to accounts of real estate agents and dealers' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'PRECIOUS_METAL_DEALER_CASH', 'High value cash transactions by dealer in precious metal or stone', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true + FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions by dealer in precious metal or stone' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NPO_CASH_HIGH_VALUE', 'High value cash transactions in NPOs', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, NULL, true, 'CHARITABLE_ORG', id, true + FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions in NPOs' LIMIT 1; + + -- NTR territory: FIU-IND expects a non-profit receipt return at ten lakh, so the figure is not the + -- bank's to choose. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NGO_RECEIPT_OF_FUNDS', 'Registered NGOs - receipt of funds in account', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, 'CREDIT', false, 'CHARITABLE_ORG', id, true + FROM m_client_risk_rule WHERE rule_name = 'Account Monitoring - Reg. NGOs - Receipt of funds in account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NPO_ONWARD_DISBURSAL', 'NPOs receiving funds and transferring to multiple beneficiaries', 'ALL', 500000, 'VELOCITY', + 30, 5, 'DEBIT', false, 'CHARITABLE_ORG', id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'NPOs or charities receiving funds%' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_RISK_CUSTOMER_HIGH_VALUE', 'High Value transactions by high risk customers', 'ALL', 500000, 'THRESHOLD', + NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true + FROM m_client_risk_rule WHERE rule_name = 'High Value transactions by high risk customers' LIMIT 1; + + -- Staff accounts. Left inactive and with no subject scope: the resolver knows PEP, charitable, high-risk + -- business and new-account only, so a STAFF scope would fall through to "matches everything" and report + -- this against every customer in the bank. Configured now, switched on when staff can be identified. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'STAFF_ACCOUNT_MONITORING', 'Monitoring accounts of Staff', 'ALL', 200000, 'THRESHOLD', + NULL, NULL, NULL, false, NULL, id, false + FROM m_client_risk_rule WHERE rule_name = 'Monitoring accounts of Staff' LIMIT 1; + + -- ── Loans and deposits ────────────────────────────────────────────────────────────────────── + -- Two lakh is section 269ST's ceiling on cash accepted in a single transaction, which is the line a cash + -- loan repayment is measured against. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'LOAN_CASH_REPAYMENT_WINDOW', 'Repayment of loan in cash over a window', 'LOAN', 200000, 'STRUCTURING', + 90, 3, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Repayment of loan in cash greater than%' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'FDR_PREMATURE_CLOSURE_LARGE', 'Premature closure of large FDR through PO/DD', 'DEPOSIT', 1000000, 'THRESHOLD', + NULL, NULL, 'DEBIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Premature closure of large FDR through PO/DD' LIMIT 1; + + -- ── Instruments and inward funds ──────────────────────────────────────────────────────────── + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'DD_DEPOSIT_IMMEDIATE_WITHDRAWAL', 'Multiple deposit of MCs/DDs followed by immediate withdrawal', 'ALL', 500000, 'VELOCITY', + 7, 3, NULL, false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Multiple deposit of Mcs/DDs%' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NUMEROUS_CREDIT_SLIPS', 'Numerous small credit slips totalling a substantial amount', 'SAVINGS', 500000, 'STRUCTURING', + 7, 5, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Depositing cash by means of numerous credit slip%' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'INWARD_REMITTANCE_HIGH_VALUE', 'High value inward remittance', 'TRANSFER', 1000000, 'THRESHOLD', + NULL, NULL, 'CREDIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High value inward remittance' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'INWARD_REMITTANCE_NEW_ACCOUNT', 'Inward remittance in a new account', 'TRANSFER', 200000, 'THRESHOLD', + 30, NULL, 'CREDIT', false, 'NEW_ACCOUNT', id, true + FROM m_client_risk_rule WHERE rule_name = 'Inward remittance in a new account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NEFT_HIGH_VALUE', 'Monitoring NEFT transactions', 'TRANSFER', 1000000, 'THRESHOLD', + NULL, NULL, NULL, false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Monitoring NEFT Transactions' LIMIT 1; + + -- ── Frequency ─────────────────────────────────────────────────────────────────────────────── + -- The catalog lists this scenario twice; FREQUENT_LOW_CASH_WITHDRAWAL already covers the first. This is + -- the monthly counterpart, over a longer window. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, min_limit_amount, max_limit_amount, source_client_risk_rule_id, is_active) + SELECT 'FREQUENT_LOW_CASH_WITHDRAWAL_MONTH', 'Frequent low cash withdrawals (monthly)', 'SAVINGS', 300000, 'VELOCITY', + 30, 8, 'DEBIT', true, NULL, 1, 50000, id, true + FROM m_client_risk_rule WHERE rule_name = 'Frequent low cash withdrawals' + AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = m_client_risk_rule.id) + LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'CHEQUE_LEAVES_HIGH_USAGE', 'High number of cheque leaves', 'ALL', 100000, 'VELOCITY', + 30, 20, 'DEBIT', false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'High number of cheque leaves' LIMIT 1; + + -- Round-figure transfers, a laundering tell the catalog describes in foreign currency but which reads + -- the same in rupees. Banded to the round lakh so ordinary amounts do not match. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, min_limit_amount, source_client_risk_rule_id, is_active) + SELECT 'ROUND_FIGURE_TRANSFERS', 'Transfers in round multiples, repeated', 'TRANSFER', 1000000, 'VELOCITY', + 30, 5, NULL, false, NULL, 100000, id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Amounts of money transfer carried out%' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'INWARD_THEN_IMMEDIATE_OUTFLOW', 'Inward remittance followed by immediate withdrawal or transfer', 'ALL', 500000, 'VELOCITY', + 2, 3, NULL, false, NULL, id, true + FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Inward remittance followed by immediate%' LIMIT 1; + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_WITHOUT_PAN' + + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_CASH_WITHOUT_PAN', 'High value cash transactions in accounts without PAN', 'ALL', 50000, 'THRESHOLD', + NULL, NULL, NULL, true, NULL, id, false + FROM m_client_risk_rule WHERE rule_name = 'High value Cash transactions in accounts without PAN' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_NONCASH_WITHOUT_PAN', 'High value non-cash transactions in accounts without PAN', 'ALL', 1000000, 'THRESHOLD', + NULL, NULL, NULL, false, NULL, id, false + FROM m_client_risk_rule WHERE rule_name = 'high value non cash transactions in accounts without PAN' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count, + direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'HIGH_VALUE_TXN_WITHOUT_PAN_ID', 'High Value Transactions without PAN ID', 'ALL', 50000, 'THRESHOLD', + NULL, NULL, NULL, false, NULL, id, false + FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions without PAN ID' LIMIT 1; + + + + + + + UPDATE m_client_risk_rule r + SET coverage_status = 'AUTOMATED', + coverage_note = 'Evaluated by the AML engine on every matching transaction.' + WHERE r.rule_kind = 'AML' + AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t + WHERE t.source_client_risk_rule_id = r.id AND t.is_active = true); + + UPDATE m_client_risk_rule r + SET coverage_status = 'PLANNED', + coverage_note = 'Rule is configured with its threshold but switched off, because the engine cannot yet ' + || 'identify this scenario''s subject from the transaction context.' + WHERE r.rule_kind = 'AML' + AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t + WHERE t.source_client_risk_rule_id = r.id AND t.is_active = false) + AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t2 + WHERE t2.source_client_risk_rule_id = r.id AND t2.is_active = true); + + -- Anything still unclassified after 0551 and this file is automatable in principle but has no rule yet. + UPDATE m_client_risk_rule + SET coverage_status = 'PLANNED', + coverage_note = 'Automatable, but no transaction rule has been configured for it yet.' + WHERE rule_kind = 'AML' AND coverage_status IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml new file mode 100644 index 000000000..c6161fffd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + One row per customer per closed observation window. Written by the review job, never by + a transaction - a single transaction is an input to a window, not a window of its own. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Read per customer, newest first, by both the report and the next run of the job. + + + + + + + + + + + The review worklist reads the customers whose rating the last window says should move. + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml new file mode 100644 index 000000000..7af939879 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + 1 Low, 2 Medium, 3 High — the worst band among all rules this transaction matched, not + only the governing one. Null where no matched rule carried a severity at all, which is + how the CTR rules arrive: they are returns the bank owes, not graded suspicions. + + + + + + + + + Backfill from the governing rule for alerts raised before this column existed. Their + also_matched rules are recorded as codes rather than ids, so the governing rule's own + severity is the most that can honestly be said about them. + + + UPDATE m_aml_alert a + SET highest_severity = ( + SELECT cr.risk_category + FROM m_aml_transaction_rule r + JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id + WHERE r.id = a.rule_id) + WHERE a.highest_severity IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml new file mode 100644 index 000000000..85892bd79 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + UPDATE m_aml_recurring_review SET escalation_tier = 'ROUTINE' WHERE escalation_tier IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml new file mode 100644 index 000000000..b1197c2f1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_aml_str_case_alert + + + INSERT INTO m_aml_str_case_alert (str_case_id, alert_id, added_on_utc) + SELECT id, alert_id, now() FROM m_aml_str_case; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml new file mode 100644 index 000000000..12d55fa3d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml @@ -0,0 +1,109 @@ + + + + + + + + + SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_kind = 'AML' AND risk_category = 1 + + + INSERT INTO m_client_risk_rule (rule_name, risk_category, rule_kind, reason, order_position, aml_check_status, coverage_status) + VALUES + ('Moderate cash deposit on a savings account', 1, 'AML', + 'Cash deposit at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 601, 'FLAGGED', 'AUTOMATED'), + ('Moderate cash withdrawal on a savings account', 1, 'AML', + 'Cash withdrawal at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 602, 'FLAGGED', 'AUTOMATED'), + ('Repeated small cash deposits in a week', 1, 'AML', + 'Several small cash deposits inside a week - ordinary for a trader, worth recording for anyone else', 603, 'FLAGGED', 'AUTOMATED'), + ('First sizeable transaction on a new account', 1, 'AML', + 'A recently opened account moving money for the first time - normal after a salary or a festival, and the point at which monitoring should start watching', 604, 'FLAGGED', 'AUTOMATED'); + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MODERATE_CASH_DEPOSIT' + + + -- Rs 50,000 to Rs 2,00,000 in cash, one transaction. Above the PAN quoting line under Rule 114B, below + -- the Rs 10,00,000 CTR line. The Medium rule over the same band asks for three inside a week; a single + -- one was previously recorded nowhere. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type, + window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'MODERATE_CASH_DEPOSIT', 'Moderate cash deposit', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD', + NULL, NULL, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Moderate cash deposit on a savings account' LIMIT 1; + + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type, + window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'MODERATE_CASH_WITHDRAWAL', 'Moderate cash withdrawal', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD', + NULL, NULL, 'DEBIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Moderate cash withdrawal on a savings account' LIMIT 1; + + -- Four or more cash credits each under Rs 50,000 inside a week, totalling Rs 1,00,000 or more. + -- Deliberately capped at 49,999 so it cannot overlap the Medium band that begins at 50,000: this is + -- frequency in small change, which is a shopkeeper ordinary week and only matters if it persists. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type, + window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'REPEATED_SMALL_CASH_DEPOSIT', 'Repeated small cash deposits in a week', 'SAVINGS', 100000, 1, 49999, + 'VELOCITY', 7, 4, 'CREDIT', true, NULL, id, true + FROM m_client_risk_rule WHERE rule_name = 'Repeated small cash deposits in a week' LIMIT 1; + + -- Rs 25,000 up to Rs 5,00,000 on an account opened within the last 180 days. NEW_ACCOUNT is the scope the + -- resolver already understands; the ceiling is where NEW_ACCOUNT_LARGE_TXN (Medium) takes over, so the + -- two never both fire. + INSERT INTO m_aml_transaction_rule + (rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type, + window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active) + SELECT 'NEW_ACCOUNT_FIRST_ACTIVITY', 'First sizeable transaction on a new account', 'SAVINGS', 25000, + 25000, 499999, 'THRESHOLD', NULL, NULL, NULL, false, 'NEW_ACCOUNT', id, true + FROM m_client_risk_rule WHERE rule_name = 'First sizeable transaction on a new account' LIMIT 1; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml new file mode 100644 index 000000000..ab0b78028 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_aml_alert_rule + + + INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by, + reviewed_on, review_notes) + SELECT a.id, r.rule_code, cr.risk_category, true, a.disposition, a.reviewed_by, a.reviewed_on, a.review_notes + FROM m_aml_alert a + JOIN m_aml_transaction_rule r ON r.id = a.rule_id + LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id; + + INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by, + reviewed_on, review_notes) + SELECT a.id, code.rule_code, cr.risk_category, false, a.disposition, a.reviewed_by, a.reviewed_on, + a.review_notes + FROM m_aml_alert a + CROSS JOIN LATERAL unnest(string_to_array(a.also_matched_rules, ',')) AS raw(rule_code) + CROSS JOIN LATERAL (SELECT trim(raw.rule_code) AS rule_code) AS code + LEFT JOIN m_aml_transaction_rule r ON r.rule_code = code.rule_code + LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id + WHERE a.also_matched_rules IS NOT NULL + AND a.also_matched_rules <> '' + AND trim(raw.rule_code) <> '' + ON CONFLICT (alert_id, rule_code) DO NOTHING; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml new file mode 100644 index 000000000..a115c90ef --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml @@ -0,0 +1,45 @@ + + + + + + + + UPDATE m_aml_alert + SET raised_on = created_on_utc + WHERE created_on_utc IS NOT NULL + AND raised_on = date_trunc('day', raised_on); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml new file mode 100644 index 000000000..e0f7b40a6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + UPDATE m_aml_alert_rule SET directly_decided = true WHERE disposition IS NOT NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml new file mode 100644 index 000000000..c9d19022f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_risk_policy_parameter + + + INSERT INTO m_risk_policy_parameter (parameter_key, parameter_value, description) VALUES + -- Upgrades: recent evidence, and still a pattern rather than one transaction. + ('UPGRADE_WINDOW_DAYS', 90, + 'Days of activity examined when considering raising a rating. Short because a concern is about what the customer is doing now.'), + ('MIN_TXN_FOR_UPGRADE', 5, + 'Transactions required in the upgrade window before a rating may be raised. One large transaction is an alert, not a pattern.'), + ('LOW_TO_MED_CASH_TOTAL', 2500000, + 'Cash through a Low-rated customer in the upgrade window that warrants Medium.'), + ('LOW_TO_MED_CTR_COUNT', 3, + 'CTR-reportable transactions in the upgrade window that warrant Medium.'), + ('LOW_TO_MED_ALERT_COUNT', 3, + 'Medium-or-worse alerts in the upgrade window that warrant Medium.'), + ('MED_TO_HIGH_CASH_TOTAL', 10000000, + 'Cash through a Medium-rated customer in the upgrade window that warrants High.'), + ('MED_TO_HIGH_ALERT_COUNT', 5, + 'High-severity alerts in the upgrade window that warrant High.'), + + -- Downgrades: long, evenly spread, and materially larger volumes of evidence. + ('HIGH_TO_MEDIUM_WINDOW_DAYS', 365, + 'Days of clean behaviour before a High rating may come down. A year, because removing High is a judgement that the earlier concern no longer holds.'), + ('HIGH_TO_MEDIUM_MIN_TXN', 20, + 'Transactions required in that year, so the rating is lowered on demonstrated behaviour rather than on quiet.'), + ('HIGH_TO_MEDIUM_MIN_MONTHS', 6, + 'Distinct calendar months the transactions must span. Twenty in one week is not a year of good conduct.'), + ('MEDIUM_TO_LOW_WINDOW_DAYS', 270, + 'Days of clean behaviour before a Medium rating may come down to Low.'), + ('MEDIUM_TO_LOW_MIN_TXN', 12, + 'Transactions required in that window.'), + ('MEDIUM_TO_LOW_MIN_MONTHS', 5, + 'Distinct calendar months those transactions must span.'), + + -- Materiality: what size of activity holds a rating up regardless of the count. + ('SMALL_TXN_MAX', 100000, + 'Largest single entry below which the period reads as small-value activity.'), + ('SMALL_TOTAL_MAX', 500000, + 'Period total below which activity is immaterial for an individual customer.'), + ('MATERIAL_TOTAL', 2500000, + 'Period total at or above which volume alone holds the rating where it is.'), + + -- Blocks: conditions under which no downgrade is offered at all. + ('STR_BLOCK_MONTHS', 24, + 'Months after an STR during which the customer''s rating may not be lowered.'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml new file mode 100644 index 000000000..1cd9467fe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml new file mode 100644 index 000000000..61b8d0400 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml @@ -0,0 +1,60 @@ + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Client Behaviour Window Computation' + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml new file mode 100644 index 000000000..2d3db98d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + From 184d1b8cf84ab927a1ec7f5b7c392ed23d1d612a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 16 Sep 2026 17:12:35 +0530 Subject: [PATCH 47/55] feat(accounting): GL-funded DD issuance, share-withdrawal GL settlement, and ledger txn-id hardening Share withdrawal (clerk): - Add GL and NONE settlement modes alongside the existing SAVINGS mode. A clerk can now credit any GL account on the CR row of a Fund Transfer instead of the member's linked savings account; picking the product's own Sundry account self-cancels, so it downgrades to NONE and the request stands on leg 1 alone. - Post Dr Sundry / Cr chosen GL for GL mode; raise a standalone Dr Share / Cr Sundry voucher for NONE mode, read back from the ledger. Sponsor DD: - Let a clerk fund a Demand Draft by debiting a GL account directly (payment_method = GL) under the existing maker-checker flow, reusing the shared postGlOffsetEntry() so only the debit-side GL resolution differs. Cash reports and vouchers: - Fix the Closing Cash Report total row and opening-balance carry-forward. - Read the real till GL back from the underlying transaction when raising withdrawal, closure and loan vouchers, instead of labelling every leg "Cash in Hand". Ledger transaction-id hardening: A derived transaction_id text (e.g. "SH" + share transaction id) is not guaranteed unique if the underlying numeric id is ever reused, which a manual sequence reset can cause. Queries that read those rows back now disambiguate, and flag flips can no longer touch an unrelated transaction: - Read-back sites pick the most recently posted matching rows (ORDER BY id DESC + LIMIT) or, where the read happens long after posting, the candidate group whose created_date sits closest to the owning request's actioned_at. - Every UPDATE ... SET reversed = true/false WHERE transaction_id = ? is now guarded by the opposite reversed state, so a collision can only flip rows that are currently live. - Covers clerk disbursement, sponsor DD, cheque book, clearing, share tranche funding and share withdrawal. Migrations 0551-0554: settlement GL account and voucher_no columns for the clerk share withdrawal, dividend claim and sponsor DD issue request tables. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/BranchDayRegisterApiResource.java | 21 +- .../api/CashierDepositRequestApiResource.java | 20 +- .../cashreport/api/CashReportApiResource.java | 234 +++++++---- .../ChequeBookIssuanceChargeService.java | 6 +- .../service/ClearingJournalService.java | 6 +- .../ClerkDisbursementRequestApiResource.java | 118 +++++- .../api/ClerkClosureRequestApiResource.java | 43 +- ...lerkShareWithdrawalRequestApiResource.java | 370 ++++++++++++++++-- .../ClerkWithdrawalRequestApiResource.java | 43 +- .../api/DividendClaimRequestApiResource.java | 10 +- ...WritePlatformServiceJpaRepositoryImpl.java | 29 +- ...WritePlatformServiceJpaRepositoryImpl.java | 8 +- .../AdditionalShareTrancheFundingService.java | 14 +- .../sponsordd/api/SponsorDDApiResource.java | 6 +- ...sorDDIssueRequestWritePlatformService.java | 2 +- ...DIssueRequestWritePlatformServiceImpl.java | 90 ++++- .../api/VoucherVerificationApiResource.java | 108 +++++ .../voucher/service/VoucherService.java | 45 ++- .../tenant/final-changelog-tenant.xml | 6 + ...ount_to_clerk_share_withdrawal_request.xml | 22 ++ ...r_no_to_clerk_share_withdrawal_request.xml | 27 ++ ...to_dividend_claim_and_dd_issue_request.xml | 38 ++ ...gl_account_to_sponsor_dd_issue_request.xml | 22 ++ 23 files changed, 1106 insertions(+), 182 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 2bdc4ac2a..701e9e4e8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -1056,13 +1056,32 @@ public class BranchDayRegisterApiResource { (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); - final BigDecimal deposits = zero(jdbcTemplate.queryForObject(""" + final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request WHERE status = 'APPROVED' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? AND (office_id = ? OR office_id IS NULL) """, BigDecimal.class, date, officeId)); + // Cash paid over the counter to buy a demand draft is real cash arriving at the branch too -- + // it was missing from this total entirely, understating expected cash by exactly what a + // walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed + // over that day. face_value + charges + GST is the fee calculation, which can land on a paisa + // fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender + // whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave + // this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and + // are correctly excluded by the payment method filter. + final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0) + FROM m_sponsor_dd_issue_request dd + WHERE dd.status = 'APPROVED' + AND dd.payment_method = 'CASH' + AND dd.issued_date = ? + AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL) + """, BigDecimal.class, date, officeId)); + + final BigDecimal deposits = savingsDeposits.add(ddCashReceipts); + // A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and // disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on // APPROVED alone silently excluded every completed withdrawal from this total, overstating diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 8060a8192..c1588aa0e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -624,7 +624,12 @@ public class CashierDepositRequestApiResource { final Map gl = glAccountForVoucher(glAccountId); credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount); } else { - credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount); + // The request's own account_no is normally right, but if a cashier ever searched by a + // short/partial account number this is where that would surface -- prefer the number + // looked up fresh from savingsAccountId, the id actually posted against. + final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId); + credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), + realAccountNo != null ? realAccountNo : accountNo, amount); } voucherService.addLines(voucherNo, List.of(debit, credit)); @@ -634,6 +639,19 @@ public class CashierDepositRequestApiResource { } /** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */ + /** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space -- + * always querying {@code m_savings_account} would silently return an unrelated savings account's + * number whenever the two id spaces happened to collide. Every other non-GL type here is a + * {@code m_savings_account} row. */ + private String savingsAccountNoOf(final String accountType, final Long accountId) { + if (accountId == null) { + return null; + } + final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account"; + return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream() + .findFirst().orElse(null); + } + private Map glAccountForVoucher(final Long glAccountId) { if (glAccountId == null) { return Map.of(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index ebad591bb..edde51cf2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -499,86 +499,52 @@ public class CashReportApiResource { // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch. final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering(); - // The predicate goes through m_appuser because m_savings_txn_denomination records the acting - // user in officer_id, not the m_cashiers row -- the same join the position query uses. - final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" - + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + // Today's own movement -- what actually crossed the counter, the vault hatch or the + // settlement window on this one date. Unchanged from before: this is informational, "what + // happened today", and stays that way regardless of what the drawer carried in from an + // earlier, unsettled day. + final Map> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId); - // Every note and coin that reached the drawer or left it, per denomination. - // - // Four legs, not one. The counter breakdown alone answered "what did customers hand over", - // which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a - // ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only - // the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be - // counted against. So the vault allocation and the settlement back are included on the same - // footing as the counter work, and each row says where its notes came from. - // - // Cashier scoping differs per leg because each table records the party differently: the - // counter tables name the acting user, the cash tables name the cashier row. - final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; - final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; + // What the drawer already held before today started -- everything ever recorded up to (but + // not including) this date, net. A cashier who force-closed yesterday without settling, then + // settled today, hands back notes that arrived on several different days; comparing that + // settlement only against today's own vault-in/deposit legs manufactured an impossible + // negative count for every denomination the earlier days had contributed. Cumulative "as of + // yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use + // for a drawer's running position, applied here as this report's opening balance. + final Map> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId); - final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, " - + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, " - + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, " - + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, " - + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, " - + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, " - + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, " - + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, " - + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount " - + "FROM (" - // Counter work: what customers handed over and were paid. - + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, " - + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg " - + " FROM m_savings_txn_denomination std " - + " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED - + " UNION ALL " - // Drawn from the vault in the morning. - + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg " - + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " - + " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false " - + vaultCashierFilter - + " UNION ALL " - // Handed back to the vault at close. - + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg " - + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id " - + " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false " - + settleCashierFilter - + ") legs " - + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id " - + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind " - + "ORDER BY legs.denomination_value DESC, dm.kind"; - - final List args = new ArrayList<>(); - args.add(txnDate); - if (scopedCashierId != null) { - args.add(scopedCashierId); - } - args.add(txnDate); - if (scopedCashierId != null) { - args.add(scopedCashierId); - } - args.add(txnDate); - if (scopedCashierId != null) { - args.add(scopedCashierId); + final Map> merged = new LinkedHashMap<>(todayLegs); + for (final Long denomId : openingLegs.keySet()) { + merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id))); } - final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { - final long vaultInCount = rs.getLong("vault_in_count"); - final long depositCount = rs.getLong("deposit_count"); - final long withdrawalCount = rs.getLong("withdrawal_count"); - final long settledOutCount = rs.getLong("settled_out_count"); - final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount"); - final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount"); - final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount"); - final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount"); + final List> rows = new ArrayList<>(); + for (final Map today : merged.values()) { + final Long denomId = (Long) today.get("denominationId"); + final Map opening = openingLegs.getOrDefault(denomId, null); + final long openingCount = opening == null ? 0L + : (long) opening.get("vaultInCount") + (long) opening.get("depositCount") + - (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount"); + final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO + : ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount")) + .subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount")); - Map row = new LinkedHashMap<>(); - row.put("denominationId", rs.getLong("denomination_id")); - row.put("denominationValue", rs.getInt("denomination_value")); - row.put("kind", rs.getString("kind")); + final long vaultInCount = (long) today.get("vaultInCount"); + final long depositCount = (long) today.get("depositCount"); + final long withdrawalCount = (long) today.get("withdrawalCount"); + final long settledOutCount = (long) today.get("settledOutCount"); + final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount"); + final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount"); + final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount"); + final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount"); + + final Map row = new LinkedHashMap<>(); + row.put("denominationId", denomId); + row.put("denominationValue", today.get("denominationValue")); + row.put("kind", today.get("kind")); + row.put("openingCount", openingCount); + row.put("openingAmount", openingAmount); row.put("vaultInCount", vaultInCount); row.put("vaultInAmount", vaultInAmount); row.put("depositCount", depositCount); @@ -587,13 +553,18 @@ public class CashReportApiResource { row.put("withdrawalAmount", withdrawalAmount); row.put("settledOutCount", settledOutCount); row.put("settledOutAmount", settledOutAmount); - // What is left of this denomination: everything that came in, less everything that went - // out. This is the figure a teller counts their notes against, per note. - row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount); - row.put("netAmount", - vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount)); - return row; - }, args.toArray()); + // What is left of this denomination: whatever the drawer already held, plus what came in + // today, less what went out today. This is the figure a teller counts their notes + // against, per note. + row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount); + row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount) + .subtract(settledOutAmount)); + rows.add(row); + } + rows.sort((a, b) -> { + final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue")); + return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind"))); + }); BigDecimal depositTotal = BigDecimal.ZERO; BigDecimal withdrawalTotal = BigDecimal.ZERO; @@ -762,6 +733,105 @@ public class CashReportApiResource { * Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing * report and the position screen can never disagree about the same drawer. */ + /** + * The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per + * denomination, with {@code dateComparison} deciding which side of the target date each leg is + * read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for + * everything before it). One query shape shared by both calls in {@link #closing}, rather than + * two copies that could drift apart. + */ + private Map> fetchLegTotals(final String dateComparison, final String dateBound, + final Long scopedCashierId) { + final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" + + " SELECT au.id FROM m_appuser au " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; + final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; + + final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, " + + "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, " + + "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, " + + "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, " + + "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount " + + "FROM (" + + " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, " + + " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg " + + " FROM m_savings_txn_denomination std " + + " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED + + " UNION ALL " + + " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg " + + " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id " + + " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false " + + vaultCashierFilter + + " UNION ALL " + + " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg " + + " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id " + + " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false " + + settleCashierFilter + + ") legs " + + "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id " + + "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind " + + "ORDER BY legs.denomination_value DESC, dm.kind"; + + final List args = new ArrayList<>(); + args.add(dateBound); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + args.add(dateBound); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + args.add(dateBound); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + + final Map> byDenomination = new LinkedHashMap<>(); + jdbcTemplate.query(sql, (rs, rowNum) -> { + final Map row = new LinkedHashMap<>(); + final Long denomId = rs.getLong("denomination_id"); + row.put("denominationId", denomId); + row.put("denominationValue", rs.getInt("denomination_value")); + row.put("kind", rs.getString("kind")); + row.put("vaultInCount", rs.getLong("vault_in_count")); + row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount")); + row.put("depositCount", rs.getLong("deposit_count")); + row.put("depositAmount", rs.getBigDecimal("deposit_amount")); + row.put("withdrawalCount", rs.getLong("withdrawal_count")); + row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount")); + row.put("settledOutCount", rs.getLong("settled_out_count")); + row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount")); + byDenomination.put(denomId, row); + return row; + }, args.toArray()); + return byDenomination; + } + + /** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination + * that carried an opening balance into this date but saw no movement of its own on it -- without + * this, that denomination would vanish from the report instead of showing its carried-forward + * count untouched. */ + private Map emptyLegRow(final Map template) { + final Map row = new LinkedHashMap<>(); + row.put("denominationId", template.get("denominationId")); + row.put("denominationValue", template.get("denominationValue")); + row.put("kind", template.get("kind")); + row.put("vaultInCount", 0L); + row.put("vaultInAmount", BigDecimal.ZERO); + row.put("depositCount", 0L); + row.put("depositAmount", BigDecimal.ZERO); + row.put("withdrawalCount", 0L); + row.put("withdrawalAmount", BigDecimal.ZERO); + row.put("settledOutCount", 0L); + row.put("settledOutAmount", BigDecimal.ZERO); + return row; + } + private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) { final Map position = buildPositionForCashier(cashierId, asOfDate); final Object total = position.get("totalAmount"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index e7e9ddcff..5c128b357 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -389,8 +389,12 @@ public class ChequeBookIssuanceChargeService { /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in + // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a + // stale, unrelated journal entry. final List rowIds = this.jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class, + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index 852f9124e..9180b5bf6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -192,8 +192,12 @@ public class ClearingJournalService { } private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in + // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a + // stale, unrelated journal entry. final List rowIds = this.jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class, + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index c9cce3fc3..4da3c64d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -145,6 +145,24 @@ public class ClerkDisbursementRequestApiResource { if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) { throw apiError(400, "Each entry must have an account number"); } + // Whatever the counter searched with to find this account (often a short suffix, not the + // full number) is not what belongs on this record permanently — replace it here, once, at + // submit time, with the real number looked up from the id the search actually resolved. + // Leaves the entry's own accountNo untouched only when it carries no id to look up from + // (a GL leg's account/glAccountId is set, so this only bites when the search itself never + // matched anything, which submit-time validation elsewhere is already responsible for). + final String entryAccountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + final boolean entryIsGl = "GL".equals(entryAccountType); + final String realAccountNo = entryIsGl + ? (entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull() + ? glCodeOf(entry.get("glAccountId").getAsLong()) + : null) + : (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull() + ? nonGlAccountNoOf(entryAccountType, entry.get("savingsAccountId").getAsLong()) + : null); + if (realAccountNo != null) { + entry.addProperty("accountNo", realAccountNo); + } // Checked by the direction of the leg. A Dr leg takes money out of the account and is // stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps // working, since nothing about a restricted customer should prevent them being paid. @@ -180,6 +198,12 @@ public class ClerkDisbursementRequestApiResource { final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); + // Same correction as each entry above, for the request's own top-level GL — and the entries + // are stored from the mutated array (each accountNo now the real one), not the original + // as-submitted JSON string. + final String realGlAccountNo = Optional.ofNullable(glCodeOf(glAccountId)).orElse(glAccountNo); + final String correctedCreditEntriesJson = gson.toJson(creditEntries); + final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_clerk_disbursement_request (gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code, @@ -189,9 +213,9 @@ public class ClerkDisbursementRequestApiResource { RETURNING id """, Long.class, - glAccountId, glAccountNo, glAccountName, txnAmount, + glAccountId, realGlAccountNo, glAccountName, txnAmount, officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode, - creditEntriesJson, paymentTypeId, paymentTypeName, description, + correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), requestType @@ -1107,6 +1131,34 @@ public class ClerkDisbursementRequestApiResource { * A failure here does not fail the approval: every transaction is posted by this point, and the * money has moved. The problem is logged, and the voucher keeps its number without sides. */ + /** The GL account's own code, looked up fresh from its id -- never trust a caller-supplied string + * for what belongs on a voucher's GL-side line. Null-safe so a possibly-missing id passes through. */ + private String glCodeOf(final Long glAccountId) { + if (glAccountId == null) { + return null; + } + return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream() + .findFirst().orElse(null); + } + + /** The account's own account number, looked up fresh from its id -- same reasoning as + * {@link #glCodeOf}, for the non-GL side of a voucher line. The id travels in this entry's + * {@code savingsAccountId} field regardless of what kind of account it actually names (this + * request's JSON has no separate field per account type), but "CURRENT" accounts live in their + * own {@code m_current_account} table with their own id space -- looking every non-GL id up + * against {@code m_savings_account} would silently return an unrelated savings account's number + * whenever the two id spaces happened to collide, which is worse than the truncated-search-string + * bug this method exists to fix. Every other non-GL type (savings, fixed deposit, recurring + * deposit) is a {@code m_savings_account} row distinguished only by {@code deposit_type_enum}. */ + private String nonGlAccountNoOf(final String accountType, final Long accountId) { + if (accountId == null) { + return null; + } + final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account"; + return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream() + .findFirst().orElse(null); + } + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, final String requestType) { @@ -1124,12 +1176,28 @@ public class ClerkDisbursementRequestApiResource { for (final JsonElement el : creditEntries) { final JsonObject entry = el.getAsJsonObject(); + final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"; + final boolean isGlEntry = "GL".equals(accountType); + + // For a Disbursement/Collection, the society's own GL side is added once as a single + // total further down (from sourceGlId, the actually-resolved id) -- not read per entry + // here. A Collection's payload names its GL leg inside creditEntries too (so the + // amounts balance for the request's own DR/CR validation at submit time), but if this + // loop also turned that same entry into a line, the GL account would be posted twice on + // one voucher: once here (wrongly signed, since entryIsDebit below ignores this entry's + // own dbCr and applies isCollection to every entry in the array) and again as the + // society's side, with counterpartyTotal inflated by the GL entry's own amount on top of + // it. A bank transfer has no separate society side and every entry, GL or not, is a real + // leg of the transfer, so nothing is skipped there. + if (!isBankTransfer && isGlEntry) { + continue; + } + final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString()); - final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null; + final String enteredAccountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null; final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull() ? entry.get("accountName").getAsString() - : accountNo; - final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + : enteredAccountNo; final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull() ? entry.get("glAccountId").getAsLong() @@ -1138,6 +1206,14 @@ public class ClerkDisbursementRequestApiResource { ? entry.get("savingsAccountId").getAsLong() : null; + // Whatever the counter searched with to find this account (often a short suffix, not + // the full number) is never what belongs on the slip. The id is what was actually + // resolved and posted against, so look its real number up from the database rather + // than trust the string the client happened to submit alongside it -- the id is the + // only part of this entry that can't be typo'd or truncated. + final String accountNo = isGlEntry ? Optional.ofNullable(glCodeOf(glId)).orElse(enteredAccountNo) + : Optional.ofNullable(nonGlAccountNoOf(accountType, savingsId)).orElse(enteredAccountNo); + // A bank transfer states each side on the entry itself; the other two directions // put every counterparty on the side opposite the society's GL. final boolean entryIsDebit = isBankTransfer @@ -1163,7 +1239,10 @@ public class ClerkDisbursementRequestApiResource { // between them — so its entries already balance on their own. if (!isBankTransfer) { final String glName = (String) record.get("gl_account_name"); - final String glNo = (String) record.get("gl_account_no"); + // Same reasoning as above: gl_account_no is whatever string was stored on this request + // at submit time, not necessarily this GL account's real code. sourceGlId is what was + // actually resolved and posted against. + final String glNo = Optional.ofNullable(glCodeOf(sourceGlId)).orElse((String) record.get("gl_account_no")); lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal) : VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal)); } @@ -1321,9 +1400,12 @@ public class ClerkDisbursementRequestApiResource { private Long findCashVaultGlId(final List txnRefs, final int typeEnum) { for (final String txnRef : txnRefs) { + // ORDER BY id DESC: if transaction_id text is ever reused (e.g. after a sequence + // reset), always prefer the most recently posted matching row over a stale one. final List> rows = jdbcTemplate.queryForList( "SELECT account_id FROM acc_gl_journal_entry" - + " WHERE transaction_id = ? AND type_enum = ? LIMIT 1", + + " WHERE transaction_id = ? AND type_enum = ? AND reversed = false" + + " ORDER BY id DESC LIMIT 1", "S" + txnRef, typeEnum); if (!rows.isEmpty()) { return ((Number) rows.get(0).get("account_id")).longValue(); @@ -1402,9 +1484,12 @@ public class ClerkDisbursementRequestApiResource { * typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault). */ private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) { + // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, + // in case the id is ever reused (e.g. after a sequence reset). final List> rows = jdbcTemplate.queryForList( "SELECT account_id FROM acc_gl_journal_entry" - + " WHERE transaction_id = ? AND type_enum = ? LIMIT 1", + + " WHERE transaction_id = ? AND type_enum = ? AND reversed = false" + + " ORDER BY id DESC LIMIT 1", fullTxnId, typeEnum); return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue(); } @@ -1417,8 +1502,11 @@ public class ClerkDisbursementRequestApiResource { private void markTxnGlEntriesReversed(final List entries) { for (final GlEntry e : entries) { if (e.txnId != null) { + // Guarded by reversed = false: if transaction_id text is ever reused, this can + // only flip rows that are currently live, never re-touch an already-reversed + // unrelated transaction's rows. jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", + "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", e.txnId); } } @@ -1429,7 +1517,7 @@ public class ClerkDisbursementRequestApiResource { for (final GlEntry e : entries) { if (e.txnId != null) { jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?", + "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true", e.txnId); } } @@ -1442,9 +1530,15 @@ public class ClerkDisbursementRequestApiResource { */ private void linkGlEntriesToSavingsAccounts(final String glTxnId, final List entries, final int typeEnum) { + // Take only the most recently posted `entries.size()` rows for this transaction_id text, + // then restore ascending id order. If the id text is ever reused (e.g. after a sequence + // reset), this keeps the positional zip below scoped to the rows this call itself just + // posted, instead of silently pulling in an older, unrelated combined entry's rows. final List rowIds = jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC", - Long.class, glTxnId, typeEnum); + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? AND reversed = false" + + " ORDER BY id DESC LIMIT ?", + Long.class, glTxnId, typeEnum, entries.size()); + java.util.Collections.reverse(rowIds); for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { final Long savingsAccId = entries.get(i).savingsAccountId; if (savingsAccId != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index a1f2a0cfe..e8f286fb1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -797,20 +797,31 @@ public class ClerkClosureRequestApiResource { } try { final Map control = voucherService.savingsControlAccount(savingsAccountId); - final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : (String) control.get("name"); final String accountNo = (String) record.get("account_no"); final List lines = new ArrayList<>(); - lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); + // This is the member's own savings account leg, not a GL posting -- debitGl(controlGlId, + // ...) named the shared Savings Control GL account here, mismatched against the member's + // own account number on the same line (the same bug class as the share-withdrawal voucher). + lines.add(VoucherLine.debitSavings(savingsAccountId, memberSideName, accountNo, closingAmount)); final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); if (roundingDirection > 0) { - lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); + final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId); + lines.add(VoucherLine.debitGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs())); } - lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); + // The same Cash at Vault / Cash at Teller account postRoundingAdjustment() actually posts + // to (resolveCashGlAccount()) — named and coded from the ledger rather than the fixed + // "Cash in Hand" / no-code placeholder this used to show, which said nothing when a reader + // wanted to confirm this voucher against the real entry. + final Map cashGlForVoucher = resolveCashGlAccount(); + final Long cashGlIdForVoucher = ((Number) cashGlForVoucher.get("id")).longValue(); + lines.add(VoucherLine.creditGl(cashGlIdForVoucher, (String) cashGlForVoucher.get("name"), glCodeOf(cashGlIdForVoucher), + cashierDisbursedAmount)); if (roundingDirection < 0) { - lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); + final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId); + lines.add(VoucherLine.creditGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs())); } final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, @@ -893,6 +904,28 @@ public class ClerkClosureRequestApiResource { * Falls back to that code where the product has no mapping yet — every product created before * the row existed is in that position, and a closure must not fail over a few paise. */ + /** The GL account's own code, for a voucher's GL-side line — never the account/party this entry + * happens to relate to. Null-safe so a possibly-missing id passes through. */ + private String glCodeOf(final Long glAccountId) { + if (glAccountId == null) { + return null; + } + return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream() + .findFirst().orElse(null); + } + + /** The GL account's own configured name, for the party label on a voucher's GL-side line — fixed + * labels like "Round Off Account" stood in for whatever the product/tenant's own chart of + * accounts actually maps the rounding/cash GL to, so a voucher read as posting against a party + * that doesn't exist in the ledger whenever the real mapped account was named differently. */ + private String glNameOf(final Long glAccountId) { + if (glAccountId == null) { + return "GL Account"; + } + return jdbcTemplate.queryForList("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream() + .findFirst().orElse("GL Account"); + } + private Long resolveRoundOffGlAccount(final Long savingsAccountId) { if (savingsAccountId != null) { final List mapped = jdbcTemplate.queryForList(""" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 7e2a365fe..e2db00cf3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -72,9 +72,10 @@ import org.springframework.transaction.annotation.Transactional; * from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the * governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which * server-side executes the real Fineract "redeemshares" command against the share account, or - * rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same - * maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share - * redemption is a book-entry, not a cash handover). + * rejects it. Settlement is always a SAVINGS book-transfer to the member's linked savings account — + * approval alone is terminal, see {@link #creditLinkedSavingsAccount} — never a cash payout at the + * teller counter (no cash denomination/disbursement leg; a share redemption is a book-entry, not a + * cash handover). */ @Path("/v1/clerk/share-withdrawal-requests") @Component @@ -100,7 +101,8 @@ public class ClerkShareWithdrawalRequestApiResource { "clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id", "activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id", "(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name", - "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" }; + "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode", + "settlement_gl_account_id" }; // "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of // the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a @@ -142,6 +144,16 @@ public class ClerkShareWithdrawalRequestApiResource { final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root); final String description = fromJsonHelper.extractStringNamed("description", root); final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root); + // 'SAVINGS' (default, and what every request submitted before this field existed carries) + // settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to + // whichever GL account the clerk picked on the CR row instead (a Sundry-style settlement that + // never touches a member's own account) — see postGlSettlement() in approve(). 'NONE' is not + // accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own + // chosen GL account turns out to be the redemption's own Sundry account. + final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root); + String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS" + : settlementModeRaw.trim().toUpperCase(); + Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root); // Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no., // amount, cheque no./date, token no., description, bank name, routing code, payment type) — // stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads @@ -169,8 +181,31 @@ public class ClerkShareWithdrawalRequestApiResource { if (meetingDate == null || meetingDate.isBlank()) { throw apiError(400, "Date of Meeting is required for a share withdrawal"); } + if (!"SAVINGS".equals(settlementMode) && !"GL".equals(settlementMode)) { + throw apiError(400, "Settlement Mode must be 'SAVINGS' or 'GL'"); + } + if ("GL".equals(settlementMode)) { + if (settlementGlAccountId == null) { + throw apiError(400, "Settlement GL Account is required for a GL settlement"); + } + GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId); + // The redemption's own leg 1 (routeRedeemIntoSundry(), unconditional) always credits the + // product's own Sundry GL -- picking that same account as the settlement target here + // would make postGlSettlement() debit and credit the identical account, a self-cancelling + // entry with no real movement that no real CBS ledger allows. Rather than reject this + // outright, treat it as what the clerk actually means: "stop here, the value stays in + // Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher + // for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone. + final Long productIdForSundryCheck = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", + Long.class, shareAccountId); + final Long glSundryForCheck = resolveGl(productIdForSundryCheck, FA_SHARES_SUSPENSE); + if (glSundryForCheck != null && glSundryForCheck.equals(settlementGlAccountId)) { + settlementMode = "NONE"; + settlementGlAccountId = null; + } + } if (settlementPaymentTypeId == null) { - throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited"); + throw apiError(400, "Settlement Payment Type is required — pick how the settlement should be credited"); } validateSettlementPaymentType(settlementPaymentTypeId); @@ -180,21 +215,23 @@ public class ClerkShareWithdrawalRequestApiResource { INSERT INTO m_clerk_share_withdrawal_request (share_account_id, account_no, client_id, client_name, requested_shares, requested_date, resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username, - activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot, + settlement_mode, settlement_gl_account_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate), resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), - currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot); + currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot, + settlementMode, settlementGlAccountId); return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } /** - * Settlement is always a book-transfer from Share Sundry to the linked savings account — never a - * cash payout at the teller counter — so cash-flagged payment types are rejected here regardless - * of what the clerk picked in the UI. + * A SAVINGS or GL settlement is always a book-transfer — never a cash payout at the teller + * counter — so cash-flagged payment types are rejected here regardless of what the clerk picked + * in the UI. */ private void validateSettlementPaymentType(final Long paymentTypeId) { final Map paymentType = jdbcTemplate @@ -400,9 +437,15 @@ public class ClerkShareWithdrawalRequestApiResource { savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue() : null; } - checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, - savingsAccountId == null ? "No linked savings account configured" - : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); + // A GL settlement never touches a savings account, so this check is reported as + // not-applicable rather than failed for a GL request — a share account with no linked + // savings is a perfectly valid GL-settlement case. + final boolean glSettlementMode = "GL".equals(record.get("settlement_mode")); + checks.add(glSettlementMode + ? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer") + : checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, + savingsAccountId == null ? "No linked savings account configured" + : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); // Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id) // when shares secure a loan against shares, so a withdrawal must not weaken that security while the @@ -478,19 +521,31 @@ public class ClerkShareWithdrawalRequestApiResource { } checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail)); - boolean glMappingOk = false; - String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; - if (savingsProductId != null && settlementPaymentTypeId != null) { - final Map mapping = jdbcTemplate.queryForList(""" - SELECT gl.name AS glName FROM acc_product_mapping apm - JOIN acc_gl_account gl ON gl.id = apm.gl_account_id - WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1 - """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null); - glMappingOk = mapping != null; - glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName") - : "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry"; + // Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a + // plain savings deposit command, which resolves its GL leg from the savings product's own + // Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement + // never goes near that mapping at all; postGlSettlement() resolves Sundry and the clerk's + // chosen GL account directly, so this check is reported not-applicable instead. + if (glSettlementMode) { + final Object glAccountIdObj = record.get("settlement_gl_account_id"); + final boolean settlementGlPresent = glAccountIdObj != null; + checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent, + settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing")); + } else { + boolean glMappingOk = false; + String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; + if (savingsProductId != null && settlementPaymentTypeId != null) { + final Map mapping = jdbcTemplate.queryForList(""" + SELECT gl.name AS glName FROM acc_product_mapping apm + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1 + """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null); + glMappingOk = mapping != null; + glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName") + : "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry"; + } + checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail)); } - checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail)); if (clientId != null) { final Map kycRow = jdbcTemplate.queryForList(""" @@ -680,12 +735,34 @@ public class ClerkShareWithdrawalRequestApiResource { final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number ? ((Number) storedSettlementPaymentTypeId).longValue() : null; - final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), - resolutionNumber, settlementPaymentTypeId); - - final String settlementNote = settlement != null - ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")" - : "No linked savings account on this share account — settlement must be completed manually."; + final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS"); + final Settlement settlement; + final GlSettlement glSettlement; + final String settlementNote; + if ("NONE".equals(settlementMode)) { + // The clerk deliberately chose to stop at leg 1 -- the redemption value stays in Share + // Sundry (already credited by routeRedeemIntoSundry() above), with no second leg routing + // it anywhere else. raiseSundryOnlyVoucher() below gives that leg its own standalone + // voucher instead of leaving it visible only as "Also posted" under some other voucher. + settlement = null; + glSettlement = null; + settlementNote = "Redemption booked to Share Sundry — no further settlement leg."; + } else if ("GL".equals(settlementMode)) { + settlement = null; + final Object glAccountIdObj = record.get("settlement_gl_account_id"); + final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null; + glSettlement = postGlSettlement(shareAccountId, shareTransactionId, requestedDateObj.toString(), settlementGlAccountId); + settlementNote = glSettlement != null + ? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId()) + : "Share transaction not found — GL settlement could not be booked."; + } else { + glSettlement = null; + settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber, + settlementPaymentTypeId); + settlementNote = settlement != null + ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")" + : "No linked savings account on this share account — settlement must be completed manually."; + } final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), settlementNote); @@ -696,7 +773,8 @@ public class ClerkShareWithdrawalRequestApiResource { settlement_amount = ?, settlement_savings_transaction_id = ? WHERE id = ? AND status = 'PENDING' """, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog, - Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null, + Timestamp.from(Instant.now()), + settlement != null ? settlement.amount() : (glSettlement != null ? glSettlement.amount() : null), settlement != null ? settlement.savingsTransactionId() : null, id); if (approvedRows == 0) { throw apiError(409, "This share withdrawal request was already actioned by another manager."); @@ -712,7 +790,9 @@ public class ClerkShareWithdrawalRequestApiResource { // Every other clerk register raises a voucher for the money it moves; this one did not, so a // redemption settled into a member's savings account left nothing on the voucher register to // read it back from. The GL entries were always written — it is the slip that was missing. - final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser); + final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser) + : "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareAccountId, record, glSettlement, currentUser) + : raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser); final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); @@ -723,7 +803,9 @@ public class ClerkShareWithdrawalRequestApiResource { if (shareTxnId != null) { res.addProperty("shareTransactionId", shareTxnId); } - if (settlement != null) { + if (glSettlement != null) { + res.addProperty("settlementAmount", glSettlement.amount()); + } else if (settlement != null) { res.addProperty("settlementAmount", settlement.amount()); res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId()); } else { @@ -786,17 +868,18 @@ public class ClerkShareWithdrawalRequestApiResource { final Long savingsAccountId = jdbcTemplate .queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream() .findFirst().orElse(null); - final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId); final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member"; - final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no")); final String savingsAccountNo = savingsAccountId == null ? null : jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId) .stream().findFirst().orElse(null); final List lines = new ArrayList<>(); - lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount())); - lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount())); + lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), settlement.amount())); + // This leg is the member's own savings account receiving the redemption proceeds, not a + // second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL + // account here, mismatched against the member's own account number on the same line. + lines.add(VoucherLine.creditSavings(savingsAccountId, memberName, savingsAccountNo, settlement.amount())); // PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their // savings account, which is the receipt series — the share account is the source, not a @@ -807,6 +890,14 @@ public class ClerkShareWithdrawalRequestApiResource { voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, settlement.savingsTransactionId()); } + // Every other request type that raises a voucher stores its own voucher_no back on + // itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers + // find it -- without this, the voucher exists in m_voucher/m_voucher_line but is + // invisible everywhere a register looks it up by its owning request, understating the + // branch's Payment total and the Day Register's expected cash by exactly this amount. + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId); + } return voucherNo; } catch (final RuntimeException e) { LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e); @@ -816,6 +907,167 @@ public class ClerkShareWithdrawalRequestApiResource { private record Settlement(BigDecimal amount, Long savingsTransactionId) {} + private record GlSettlement(BigDecimal amount, Long glAccountId) {} + + /** + * Books the redemption value to whichever GL account the clerk picked on the CR row of the Fund + * Transfer, routed Dr Share Sundry / Cr that GL account — the GL counterpart to + * {@link #creditLinkedSavingsAccount} for a settlement that never touches a member's own account + * (e.g. crediting a "DD Payable" holding account, so a second, independent manual entry can later + * move the value out to the actual demand draft). Posted in full at approval time, exactly like + * the Sundry leg before it. + * + *

+ * Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded — + * the redemption value posts to the paisa, same as the linked-savings path. + */ + private GlSettlement postGlSettlement(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr, + final Long settlementGlAccountId) { + if (shareTransactionId == null) { + return null; + } + if (settlementGlAccountId == null) { + throw apiError(409, "No settlement GL account was recorded on this request."); + } + final Map row = jdbcTemplate.queryForList(""" + SELECT sat.amount AS settlementAmount FROM m_share_account_transactions sat WHERE sat.id = ? + """, shareTransactionId).stream().findFirst().orElse(null); + if (row == null) { + return null; + } + final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount"); + + final Map acct = loadShareAccountGlContext(shareAccountId); + final Long productId = toLong(acct.get("product_id")); + final Long officeId = toLong(acct.get("office_id")); + final String currencyCode = (String) acct.get("currency_code"); + final String accountNo = (String) acct.get("account_no"); + + final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + + final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10)); + final long userId = context.authenticatedUser().getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String txnId = "SHRGLSTL-" + System.nanoTime(); + + insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share redemption settled to GL account | " + accountNo); + insertEntry(officeId, settlementGlAccountId, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share redemption settlement credited to GL account | " + accountNo); + + return new GlSettlement(settlementAmount, settlementGlAccountId); + } + + /** + * The voucher for an approved GL redemption: Dr Share Sundry, Cr the clerk-chosen settlement GL + * account — the same two accounts {@link #postGlSettlement} actually posted to, both named and + * coded from the ledger since both are real GL accounts here (unlike the linked-savings path, + * there is no member-account leg on this side at all). + */ + private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map record, + final GlSettlement glSettlement, final AppUser currentUser) { + if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null + || glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + try { + final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, + shareAccountId); + final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE); + final Long glTarget = glSettlement.glAccountId(); + + final List lines = new ArrayList<>(); + lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), glSettlement.amount())); + lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount())); + + // PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits + // a member's own account -- the value is moving out of Sundry toward an eventual payout + // (e.g. a second, independent manual entry crediting a "DD Payable" account out to the + // actual draft), which is the payment series, not a receipt. + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, + VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + // Every other request type that raises a voucher stores its own voucher_no back on + // itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers + // find it -- without this, the voucher exists in m_voucher/m_voucher_line but is + // invisible everywhere a register looks it up by its owning request. + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId); + } + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a GL voucher for share withdrawal request {}", requestId, e); + return null; + } + } + + /** + * The voucher for a request that settles nowhere further than leg 1 itself (settlement mode + * 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #routeRedeemIntoSundry} just + * posted, given its own standalone voucher instead of only being visible as "Also posted" under + * some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}). + * + *

+ * Read back from {@code acc_gl_journal_entry} by the native transaction's own transaction_id + * (the same technique {@code relatedGlEntries()} uses) rather than re-resolving the Shares + * Equity GL directly — there is no {@code FA_SHARES_EQUITY} constant in this class because that + * leg has never been posted by this class itself, only by Fineract's own native redeem command; + * reading back what was actually posted is correct regardless of which GL that turns out to be. + * + *

+ * Capped to the 2 most recently inserted matching rows (highest id), not every row under that + * transaction_id: {@code "SH" + shareTransactionId} is only unique so long as + * {@code m_share_account_transactions.id} never repeats, which a reset/reseeded id sequence in a + * dev/test environment can violate — an older, unrelated redemption that happened to land on the + * same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total + * shown here even though only one pair was ever posted for this approval. This call runs + * immediately after {@link #routeRedeemIntoSundry} in the same approve(), so the highest-id pair + * is always the one it just inserted. + */ + private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) { + if (!voucherService.shouldRaiseVoucher() || shareTransactionId == null) { + return null; + } + try { + final List nativeTxnIds = jdbcTemplate.queryForList( + "SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", String.class, + shareTransactionId); + if (nativeTxnIds.isEmpty()) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT je.type_enum, je.amount, ga.id AS gl_id, ga.name AS gl_name, ga.gl_code + FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id + WHERE je.transaction_id = ? AND je.reversed = false + ORDER BY je.id DESC + LIMIT 2 + """, nativeTxnIds.get(0)); + if (rows.isEmpty()) { + return null; + } + // Restore Dr-then-Cr reading order after the DESC/LIMIT above. + java.util.Collections.reverse(rows); + + final List lines = new ArrayList<>(); + for (final Map r : rows) { + final Long glId = ((Number) r.get("gl_id")).longValue(); + final BigDecimal amount = (BigDecimal) r.get("amount"); + final boolean isDebit = ((Number) r.get("type_enum")).intValue() == DEBIT; + lines.add(isDebit ? VoucherLine.debitGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount) + : VoucherLine.creditGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount)); + } + + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, + VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId); + } + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a Sundry-only voucher for share withdrawal request {}", requestId, e); + return null; + } + } + /** * Credits the redemption value to the share account's linked savings account, if one exists. * Reads the amount straight off the just-created redeem transaction row rather than re-deriving @@ -875,7 +1127,12 @@ public class ClerkShareWithdrawalRequestApiResource { // The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather // than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands // on its own regardless of which GL the payment type's Fund Source mapping happened to hit. - jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId); + // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. + // after a sequence reset), this can only flip rows that are currently live, never + // re-touch an already-reversed unrelated transaction's rows. + jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", + "S" + savingsTransactionId); final Map acct = loadShareAccountGlContext(shareAccountId); final Long productId = toLong(acct.get("product_id")); @@ -1050,6 +1307,39 @@ public class ClerkShareWithdrawalRequestApiResource { } } + /** The GL account's own code, for the {@code accountNo} printed on a voucher's GL-side line -- + * never the account/party this GL entry happens to relate to, which is what the "name" already + * carries. Null-safe so a caller can pass a possibly-missing gl account id straight through. */ + private String glCodeOf(final Long glAccountId) { + if (glAccountId == null) { + return null; + } + try { + return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId); + } catch (final IncorrectResultSizeDataAccessException e) { + return null; + } + } + + /** The GL account's own configured name (e.g. "SUNDRY CREDITORS A/C"), for the party label on a + * voucher's GL-side line -- fixed labels like "Share Sundry" or "Cash in Hand" used to stand in + * for whatever the tenant's own chart of accounts actually maps to (shares-suspense, cash at + * vault), so a voucher read as crediting/debiting a party that doesn't exist in the ledger at all + * whenever the real mapped account was named differently. Null-safe: a missing/unmapped account + * falls back to a neutral label rather than one of those specific fixed names, which would be + * actively wrong when reused for a different GL account (as glCodeOf's callers do). */ + private String glNameOf(final Long glAccountId) { + if (glAccountId == null) { + return "GL Account"; + } + try { + final String name = jdbcTemplate.queryForObject("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId); + return name != null ? name : "GL Account"; + } catch (final IncorrectResultSizeDataAccessException e) { + return "GL Account"; + } + } + private Long savingsControlGlOf(final Long savingsAccountId) { final List> rows = jdbcTemplate.queryForList(""" SELECT apm.gl_account_id FROM m_savings_account sa diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 4a7b680b4..8e2891297 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -484,7 +484,7 @@ public class ClerkWithdrawalRequestApiResource { // The sides of the slip. A withdrawal is money leaving the member, so their account is // debited and the till that pays it out is credited. - addWithdrawalVoucherLines(record, savingsAccountId, amount); + addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null @@ -735,28 +735,61 @@ public class ClerkWithdrawalRequestApiResource { * Records the two sides of an approved withdrawal on its voucher. * * Money leaves the member and is paid out over the counter, so the member's account is debited - * and the till is credited. The withdrawal register does not carry a GL id for the till — it - * pays out of whichever cashier's drawer serves the request — so that side is named without one. + * and the till is credited. The till side can't be resolved from config the way a Share or + * Closure withdrawal's cash leg can: which GL Fineract's native withdrawal command actually + * credits depends on the request's own payment type (Cash, Cheque, DD, ...) via its Fund Source + * mapping, which this class does not re-derive. Reading back what was actually posted for this + * transaction (see resolveTillGlFromSavingsTxn()) names it accurately for any payment type; + * "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a current + * account withdrawal, or a tenant with accounting off). * * A failure here does not fail the approval: the withdrawal is already posted and the member * already paid. The problem is logged, and the voucher keeps its number without sides. */ - private void addWithdrawalVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount) { + private void addWithdrawalVoucherLines(final Map record, final Long savingsAccountId, final BigDecimal amount, + final String savingsTxnId) { final String voucherNo = (String) record.get("voucher_no"); if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) { return; } try { + final Map tillGl = resolveTillGlFromSavingsTxn(savingsTxnId); voucherService.addLines(voucherNo, List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"), (String) record.get("account_no"), amount), - VoucherLine.creditGl(null, "Cash in Hand", null, amount))); + tillGl == null ? VoucherLine.creditGl(null, "Cash in Hand", null, amount) + : VoucherLine.creditGl(((Number) tillGl.get("id")).longValue(), (String) tillGl.get("name"), + (String) tillGl.get("code"), amount))); } catch (final RuntimeException e) { LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e); } } + /** The GL account Fineract's native withdrawal command actually credited for this savings + * transaction — looked up by the {@code savings_transaction_id} FK on {@code + * acc_gl_journal_entry} rather than assumed, since the real posting depends on the withdrawal's + * own payment type. Returns null (falls back to a generic label) for a current-account + * withdrawal, which has no savings transaction to match, or a tenant with accounting off. */ + private Map resolveTillGlFromSavingsTxn(final String savingsTxnId) { + if (savingsTxnId == null) { + return null; + } + final Long id; + try { + id = Long.valueOf(savingsTxnId); + } catch (final NumberFormatException e) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT g.id, g.name, g.gl_code AS code FROM acc_gl_journal_entry je + JOIN acc_gl_account g ON g.id = je.account_id + WHERE je.savings_transaction_id = ? AND je.type_enum = 1 AND je.reversed = false + LIMIT 1 + """, id); + return rows.isEmpty() ? null : rows.get(0); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index 599aaad8c..71ca09351 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -571,12 +571,18 @@ public class DividendClaimRequestApiResource { final Map debitGl = glAccountForVoucher(debitGlId); final Map creditGl = glAccountForVoucher(creditGlId); - voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser, - VoucherService.PREFIX_DEBIT, + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, + currentUser, VoucherService.PREFIX_DEBIT, List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount) .withDescription(description), VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount) .withDescription(description))); + // Every other maker-checker request that raises a voucher stores its own voucher_no back + // on itself, which is how "My Voucher"/"My Work" finds it -- without this, the voucher + // existed in m_voucher/m_voucher_line but was invisible to the manager who approved it. + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET voucher_no = ? WHERE id = ?", voucherNo, claimId); + } } catch (final RuntimeException e) { log.error("Could not raise a voucher for dividend claim {}", claimId, e); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index b180a5365..d7c67ed28 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -1111,19 +1111,34 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf try { final AppUser currentUser = this.context.authenticatedUser(); final Map portfolio = this.voucherService.loanPortfolioAccount(loan.getId()); - final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue(); final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null; final String accountNo = loan.getAccountNumber(); final String partyName = party != null ? party : (String) portfolio.get("name"); - final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount) - : VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount); + // This is the borrower's own loan account leg, not a GL posting -- debitGl/creditGl( + // portfolioGlId, ...) named the shared Loan Portfolio GL account here, mismatched against + // this borrower's own loan account number on the same line. + final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitLoan(loan.getId(), partyName, accountNo, amount) + : VoucherLine.creditLoan(loan.getId(), partyName, accountNo, amount); - // The till. The loan module does not resolve a GL for the cashier's drawer, so the line - // names it without an id rather than guessing at one. - final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount) - : VoucherLine.debitGl(null, "Cash in Hand", null, amount); + // The till. Which GL the native disbursement/repayment command actually posted here + // depends on the transaction's own payment type (Cash, Cheque, DD, ...) via its Fund + // Source mapping, so it's read back from what was actually posted rather than assumed -- + // "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a + // non-cash payment type routed elsewhere, or accounting off for this tenant). + final Map tillGl = this.voucherService.tillGlFromLoanTransaction(transactionId, isDisbursement); + final VoucherLine cashSide; + if (tillGl.isEmpty()) { + cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount) + : VoucherLine.debitGl(null, "Cash in Hand", null, amount); + } else { + final Long tillGlId = ((Number) tillGl.get("id")).longValue(); + final String tillGlName = (String) tillGl.get("name"); + final String tillGlCode = (String) tillGl.get("gl_code"); + cashSide = isDisbursement ? VoucherLine.creditGl(tillGlId, tillGlName, tillGlCode, amount) + : VoucherLine.debitGl(tillGlId, tillGlName, tillGlCode, amount); + } final List lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 7030dd9b0..3d347f4ff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -2079,7 +2079,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi final Map control = this.voucherService.savingsControlAccount(account.getId()); final Map expense = this.voucherService.savingsInterestExpenseAccount(account.getId()); - final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue(); final String party = account.getClient() != null ? account.getClient().getDisplayName() : null; @@ -2100,8 +2099,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION, transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL, List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount) - .withDescription("Interest posted to " + accountNo), - VoucherLine.creditGl(controlGlId, partyName, accountNo, amount) + .withDescription("Interest posted to " + accountNo), + // The member's own savings account leg, not a GL posting -- creditGl( + // controlGlId, ...) named the shared Savings Control GL account here, + // mismatched against this account's own number on the same line. + VoucherLine.creditSavings(account.getId(), partyName, accountNo, amount) .withDescription("Interest credited"))); // Without this the voucher exists but nothing ties it to the transaction it paid, so // the register cannot show the two together and the transaction shows no voucher_no. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index 2986803f5..68b73f81b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -220,7 +220,12 @@ public class AdditionalShareTrancheFundingService { // as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in // over the counter — so the cash debit is replaced by a Sundry debit below. final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note); - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. + // after a sequence reset), this can only flip rows that are currently live, never + // re-touch an already-reversed unrelated transaction's rows. + this.jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", + "S" + savingsTxnId); // Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the // till, so that credit is reversed; the Sundry debit it raised alongside is correct and @@ -344,7 +349,12 @@ public class AdditionalShareTrancheFundingService { // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL // pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch. final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note); - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); + // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. + // after a sequence reset), this can only flip rows that are currently live, never + // re-touch an already-reversed unrelated transaction's rows. + this.jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", + "S" + savingsTxnId); // Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry, // assuming cash over the counter. BOTH legs are reversed, not just the reference debit: diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index 5a272502f..3309b249c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -279,7 +279,8 @@ public class SponsorDDApiResource { request.getChequeNumber(), request.getChequeBankName(), request.getCashierDepositRequestId(), - request.getDenominationData() + request.getDenominationData(), + request.getGlAccountId() )); } @@ -673,6 +674,9 @@ public class SponsorDDApiResource { // that received this cash, credited to the DD Cash Suspense GL account. private Long cashierDepositRequestId; private String denominationData; + // GL only — the GL account the clerk chose to debit directly, instead of a member's own + // account, a cheque, or cash. + private Long glAccountId; } @lombok.Data diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java index 498e8fc05..1e1315337 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService { String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch, String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate, String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, - Long cashierDepositRequestId, String denominationData); + Long cashierDepositRequestId, String denominationData, Long glAccountId); /** * Manager/head-cashier (checker) step: performs the real debit/credit GL posting diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index d8febf463..aaa36da49 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -60,6 +60,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT"; private static final String METHOD_CHEQUE = "CHEQUE"; private static final String METHOD_CASH = "CASH"; + // A clerk debits a GL account directly (an institutional/holding account) instead of a member's + // own account, a cheque, or cash over the counter -- no client, no balance check, no walk-in + // purchaser details. + private static final String METHOD_GL = "GL"; /** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */ private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST"; @@ -101,7 +105,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, final String chequeNumber, final String chequeBankName, - final Long cashierDepositRequestId, final String denominationData) { + final Long cashierDepositRequestId, final String denominationData, final Long glAccountId) { final AppUser currentUser = this.context.authenticatedUser(); @@ -177,6 +181,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI issuingBranchId, issuedDate, receiptComments, null); cashReceiptVoucherPending = true; } + } else if (METHOD_GL.equals(paymentMethod)) { + // No client, no walk-in purchaser -- an institutional draft funded straight from a GL + // account. Debiting the same account the draft is credited to (DD Payable) would be a + // self-cancelling entry with no real movement, the same bug class the Share GL-settlement + // check exists to catch. + if (glAccountId == null) { + throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance."); + } + final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + if (glAccountId.equals(ddPayableGlAccountId)) { + final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); + throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account (" + + ddPayable.get("gl_code") + ") this draft is credited to — pick a different GL account to debit."); + } } else { validateClientExists(clientId); if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account"); @@ -200,8 +218,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, " + "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, " + "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, " - + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data, gl_account_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); ps.setLong(1, leafletId); if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); @@ -228,6 +246,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT); if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT); ps.setString(25, denominationData); + if (glAccountId != null) ps.setLong(26, glAccountId); else ps.setNull(26, java.sql.Types.BIGINT); return ps; }, kh); @@ -441,8 +460,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private Long findJournalEntryIdForCashierDeposit(final Map depositRequest, final Long cashSuspenseGlAccountId) { final String txnId = (String) depositRequest.get("txn_id"); if (txnId == null) return null; + // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in + // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a + // stale, unrelated journal entry. final List> rows = this.jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId); + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? AND reversed = false" + + " ORDER BY id DESC LIMIT 1", txnId, cashSuspenseGlAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); } @@ -534,6 +557,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } } else if (METHOD_CHEQUE.equals(paymentMethod)) { drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); + } else if (METHOD_GL.equals(paymentMethod)) { + // The clerk's own chosen GL account -- no withdrawal command, no linked member account; + // submitIssueRequest() already confirmed it exists and differs from DD Payable. + drGlAccountId = ((Number) request.get("gl_account_id")).longValue(); } else if (METHOD_DIVIDEND.equals(paymentMethod)) { // Nothing is collected from the member — the society already owes them this money — so // the draft is funded by settling the Dividend Payable liability instead of debiting a @@ -1237,8 +1264,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) { throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits."); } + } else if (METHOD_GL.equals(paymentMethod)) { + if (clientId != null) { + throw new IllegalArgumentException("Do not provide a Client ID for a GL-funded DD issuance — it is not raised against a member."); + } + if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) { + throw new IllegalArgumentException("Do not provide an account or cheque details when funding a draft from a GL account."); + } } else { - throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH."); + throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, CASH, or GL."); } } @@ -1325,7 +1359,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. */ private void markWithdrawalGlEntriesReversed(final String transactionId) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId); + // Guarded by reversed = false: if transaction_id text is ever reused, this can only flip + // rows that are currently live, never re-touch an already-reversed unrelated transaction. + this.jdbcTemplate.update( + "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", + transactionId); } /** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */ @@ -1374,8 +1412,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total) .withDescription("Held pending DD approval")); - this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId, - currentUser, VoucherService.PREFIX_CREDIT, lines); + // Every other maker-checker request that raises a voucher stores its own voucher_no back + // on itself, which is how "My Voucher"/"My Work" finds it. A DD request can raise up to + // three vouchers across its life (this intake receipt, an approval issuance, or a + // rejection refund), so the column holds whichever was raised most recently -- still far + // better than the total blank it was before, and this is the one that exists on every DD + // paid in cash, receipt or not. + final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, + issueRequestId, currentUser, VoucherService.PREFIX_CREDIT, lines); + if (voucherNo != null) { + this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId); + } } catch (final RuntimeException e) { log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e); } @@ -1406,8 +1453,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total) .withDescription("Cash refunded to purchaser")); - this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId, - currentUser, VoucherService.PREFIX_DEBIT, lines); + // See the same lines in raiseCashReceiptVoucher() above for why this is required. + final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, + issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + if (voucherNo != null) { + this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId); + } } catch (final RuntimeException e) { log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e); } @@ -1467,8 +1518,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .withDescription("GST on DD charges")); } - this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId, - currentUser, VoucherService.PREFIX_DEBIT, lines); + // See the same lines in raiseCashReceiptVoucher() above for why this is required. + final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, + issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + if (voucherNo != null) { + this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId); + } } catch (final RuntimeException e) { log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e); } @@ -1592,14 +1647,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String transactionId = result != null ? result.getTransactionId() : null; if (transactionId == null) return null; + // transactionId was just minted by the createJournalEntry command above, in this same + // method invocation - but scope every read/write below to the single most recently + // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever + // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. if (linkedAccountId != null) { this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2", + "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" + + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + + " ORDER BY id DESC LIMIT 1)", linkedAccountId, transactionId); } final List> rows = this.jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId); + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND reversed = false" + + " ORDER BY id DESC LIMIT 1", transactionId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index ae91fc825..a52042032 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -748,9 +748,22 @@ public class VoucherVerificationApiResource { UNION ALL SELECT voucher_no, manager_id, manager_name, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL UNION ALL SELECT voucher_no, manager_id, manager_name, actioned_at FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, manager_id, manager_name, actioned_at + FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL + UNION ALL + -- This table only ever stored the checker's id, never a name snapshot the + -- way every other register here does, so approver_name comes back null and + -- the outer query's COALESCE to the verification row's name is what fills it + -- in on this one register. + SELECT voucher_no, checker_id, CAST(NULL AS VARCHAR), checker_date + FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL ), -- Who submitted the request the voucher belongs to. -- @@ -766,8 +779,14 @@ public class VoucherVerificationApiResource { SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL UNION ALL SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL UNION ALL SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, clerk_id, status FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL + UNION ALL + SELECT voucher_no, maker_id, status FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL ) SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at, v.created_by_username, o.name AS branch_name, @@ -917,6 +936,94 @@ public class VoucherVerificationApiResource { } } + /** + * The other side of a split transaction — GL entries a native platform command posted on its own, + * outside this voucher, for the same underlying event. + * + * Most vouchers already ARE the whole transaction: {@code raiseLoanVoucher()}, + * {@code raiseClosureVoucher()} and the rest each build every line themselves from the amount they + * are posting, so there is nothing else to find. Share redemption is the one place today where + * that is not true: approval runs the native {@code redeemshares} command, which posts its own + * Dr Share Capital / Cr Share Sundry pair directly (no voucher raised for it, since it is not this + * class that posts it). Only the later savings-settlement leg (Dr Sundry / Cr Savings Control) + * becomes a voucher. Showing just that half leaves a reader unable to see where the money + * actually came from. + * + * Extending this for a future split transaction is one more {@code if} branch keyed on + * referenceType, in the same shape as the share-withdrawal one below — not a new endpoint or a new + * field on the response. + */ + private List> relatedGlEntries(final String referenceType, final Object referenceId) { + if (!VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST.equals(referenceType) || referenceId == null) { + return List.of(); + } + final List> requestRows = jdbcTemplate.queryForList( + "SELECT share_transaction_id, settlement_mode, actioned_at FROM m_clerk_share_withdrawal_request " + + "WHERE id = ? AND share_transaction_id IS NOT NULL", + referenceId); + if (requestRows.isEmpty()) { + return List.of(); + } + // A settlement_mode 'NONE' request's own voucher (raiseSundryOnlyVoucher()) already IS leg 1 + // in full -- Dr Share Capital, Cr Share Sundry -- so showing it again here as "related" would + // duplicate this voucher's own lines under a banner claiming they are not part of it. + if ("NONE".equals(requestRows.get(0).get("settlement_mode"))) { + return List.of(); + } + final Long shareTxnId = Long.parseLong(String.valueOf(requestRows.get(0).get("share_transaction_id"))); + final java.sql.Timestamp actionedAt = (java.sql.Timestamp) requestRows.get(0).get("actioned_at"); + // Matched by transaction_id, not share_transaction_id: routeRedeemIntoSundry() reroutes the + // native posting's Cr Shares Reference leg by reversing it and inserting a fresh Cr Sundry row + // that carries the same transaction_id but no share_transaction_id of its own (only the native + // rows get that column populated) -- filtering on share_transaction_id found the Dr Link Share + // leg but silently dropped its Cr Sundry counterpart, showing only half of a balanced entry. + // transaction_id is what groups a balanced entry in this codebase (see routeRedeemIntoSundry's + // own comment), and the savings-settlement leg raiseShareWithdrawalVoucher() itself posts + // carries its own distinct transaction_id, so this still cleanly excludes it — that leg is + // already covered by this voucher's own lines. + // + // "SH" + shareTransactionId is only unique so long as m_share_account_transactions.id never + // repeats, which a reset/reseeded id sequence in a dev/test environment can violate -- two + // unrelated redemptions landing on the same id would otherwise share one transaction_id, and + // this query would return both pairs instead of the one this specific request actually + // posted. Disambiguated by picking whichever candidate transaction_id's own entry_date sits + // closest to actioned_at (when this request was approved), rather than assuming there is + // only ever one. + final List> nativeTxnCandidates = actionedAt == null + ? jdbcTemplate.queryForList("SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", + shareTxnId) + : jdbcTemplate.queryForList(""" + SELECT transaction_id FROM acc_gl_journal_entry + WHERE share_transaction_id = ? + GROUP BY transaction_id + ORDER BY MIN(ABS(EXTRACT(EPOCH FROM (created_date - ?::timestamp)))) ASC + LIMIT 1 + """, shareTxnId, actionedAt); + if (nativeTxnCandidates.isEmpty()) { + return List.of(); + } + final String nativeTxnId = String.valueOf(nativeTxnCandidates.get(0).get("transaction_id")); + final List> rows = jdbcTemplate.queryForList(""" + SELECT je.entry_date, je.type_enum, je.amount, je.description, ga.name AS gl_name, ga.gl_code + FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id + WHERE je.transaction_id = ? AND je.reversed = false + ORDER BY je.id + """, nativeTxnId); + + final List> out = new java.util.ArrayList<>(); + for (final Map r : rows) { + final Map m = new LinkedHashMap<>(); + m.put("stage", "Posted at manager approval"); + m.put("entryType", ((Number) r.get("type_enum")).intValue() == 2 ? "DR" : "CR"); + m.put("partyName", r.get("gl_name")); + m.put("accountNo", r.get("gl_code")); + m.put("amount", r.get("amount")); + m.put("description", r.get("description")); + out.add(m); + } + return out; + } + // ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ──────────────────────── // A query parameter for the same reason as verify: a formatted voucher number contains // slashes, which the strict firewall rejects even encoded, so it cannot be a path segment. @@ -1000,6 +1107,7 @@ public class VoucherVerificationApiResource { // existed, or repaired by hand in the database, should show as unbalanced rather than be // presented as a valid slip. out.put("balanced", totalDebit.compareTo(totalCredit) == 0); + out.put("relatedLines", relatedGlEntries((String) h.get("reference_type"), h.get("reference_id"))); return gson.toJson(out); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index d20398465..0fbbf4813 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -326,6 +326,8 @@ public class VoucherService { final Map party = savingsPartyForTransaction(savingsTxnId); final String partyName = (String) party.get("party_name"); final String accountNo = (String) party.get("account_no"); + final Long savingsAccountId = party.get("savings_account_id") == null ? null + : ((Number) party.get("savings_account_id")).longValue(); final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue(); final List lines = new ArrayList<>(); @@ -335,13 +337,24 @@ public class VoucherService { // The member's own side is named after the member; every other leg is named after // its GL account, which is what a voucher shows for an income or expense head. final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId); - final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name"); - final String ref = isMemberSide ? accountNo : (String) row.get("gl_code"); // Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. final int typeEnum = ((Number) row.get("type_enum")).intValue(); - VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount) - : VoucherLine.creditGl(glId, name, ref, amount); + final boolean isDebit = typeEnum == 2; + + final VoucherLine line; + if (isMemberSide) { + // This is the member's own savings account leg, not a GL posting -- pairing the + // shared Savings Control GL id with the member's own account number on a debitGl/ + // creditGl line mismatched the two; debitSavings/creditSavings name the account + // this actually is. + line = isDebit ? VoucherLine.debitSavings(savingsAccountId, partyName, accountNo, amount) + : VoucherLine.creditSavings(savingsAccountId, partyName, accountNo, amount); + } else { + final String name = (String) row.get("gl_name"); + final String ref = (String) row.get("gl_code"); + line = isDebit ? VoucherLine.debitGl(glId, name, ref, amount) : VoucherLine.creditGl(glId, name, ref, amount); + } final String description = (String) row.get("description"); lines.add(description == null ? line : line.withDescription(description)); } @@ -370,6 +383,7 @@ public class VoucherService { private Map savingsPartyForTransaction(final Long savingsTxnId) { return jdbcTemplate.queryForList(""" SELECT COALESCE(c.display_name, g.display_name) AS party_name, + sa.id AS savings_account_id, sa.account_no, apm.gl_account_id AS control_gl_id FROM m_savings_account_transaction t @@ -536,6 +550,29 @@ public class VoucherService { .orElse(Map.of()); } + /** + * The GL account Fineract's native loan disbursement/repayment command actually posted the cash + * leg to, for a voucher's till side — looked up by the {@code loan_transaction_id} FK on {@code + * acc_gl_journal_entry} rather than assumed, since which GL that command credits (a disbursement) + * or debits (a repayment) depends on the transaction's own payment type via its Fund Source + * mapping, not a single fixed cash account. Empty rather than absent if the transaction posted no + * matching leg (a non-cash payment type routed elsewhere, or accounting off for this tenant) — + * callers show a generic label in that case rather than a wrong or missing one. + */ + public Map tillGlFromLoanTransaction(final Long loanTransactionId, final boolean wantCreditLeg) { + if (loanTransactionId == null) { + return Map.of(); + } + // type_enum: 1 = credit, 2 = debit. + final int typeEnum = wantCreditLeg ? 1 : 2; + return jdbcTemplate.queryForList(""" + SELECT g.id, g.name, g.gl_code FROM acc_gl_journal_entry je + JOIN acc_gl_account g ON g.id = je.account_id + WHERE je.loan_transaction_id = ? AND je.type_enum = ? AND je.reversed = false + LIMIT 1 + """, loanTransactionId, typeEnum).stream().findFirst().orElse(Map.of()); + } + /** * The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or * one whose accounts were never recorded — callers show what they have rather than treating an diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 464e245d9..2f0be1dc1 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -102,4 +102,10 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..5136078b7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..3af1550ea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml new file mode 100644 index 000000000..b67b5cdb6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml new file mode 100644 index 000000000..a3a3ca18f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + From b779d045713dfbf568083f0384a3428bce843a63 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 17 Sep 2026 12:54:04 +0530 Subject: [PATCH 48/55] fix(accounting): ledger transaction-id integrity, share GL correctness, and validation fixes Ledger transaction ids: - New LedgerTransactionId generator (PREFIX-YYMMDD-NNNN, e.g. DIVRND-260916-0001) backed by a dedicated m_ledger_txn_sequence counter (migration 0563) that belongs to no business table, so no table's sequence reset can make an id repeat. Migrated 18 hand-rolled id sites (dividend, branch day, closure, year-end, share collection/advance) off ad hoc nanoTime()/PK-concatenation strings. - ShareAccount.getShareAccountTransaction() matched a just-saved transaction by date+shares+price+ type and took the first hit -- two identical transactions on the same day resolved to the wrong one and posted under the older transaction's id. Now matches by id where one exists. Share accounting -- Cash at Vault fully retired from the share account/product lifecycle: - New ShareAccountingSuppression lets a register that posts its own accounting tell the platform not to post its native version (mirrors VoucherService.withOwnVoucher). Wired into share withdrawal, additional-shares apply/approve/reject and account close, so every governed flow now posts one balanced entry against the real counterparty instead of the platform's fixed Shares Reference placeholder, reversed and reposted. - Modify Application and Reject (the account's first application, pre-approval) no longer post via the native bridge at all -- no money has moved at that stage, so there was nothing to post. - The native "close" command no longer force-redeems a share account's remaining holding in one unguarded step; closing with a balance outstanding is now refused, so redemption can only happen through the governed Share Withdrawal flow the frontend already restricts it to. - "Share Reference (Cash Vault or Suspense)" is optional on the product, not mandatory: validator, GL mapping write/merge (including removing a stale mapping when the field is cleared on edit), and AccountingProcessorHelper's resolver (now throws a named error instead of NPEing when a product carries no mapping) all updated together. Removed from the product UI entirely (create, edit, preview, view). - AdditionalShareTrancheFundingService: submit posts nothing; settle() posts the whole entry itself, debiting whichever GL a voucher receipt actually landed in (read back from its own posting) or the member's savings account, crediting Share Capital. A voucher-funded tranche whose receipts total more than it costs now credits the difference to the member's linked savings account -- every named receipt is visited, not just enough to cover the tranche, since one left untouched here was still marked APPLIED and its money left untraceable. - ShareAccountDataSerializer: redeeming every share down to zero is exempt from the product's minimum-shares check (the check protects an account staying open with a reduced holding, not one being closed via a full exit); an over-payment on a voucher-funded tranche is refused at submit when no savings account is linked to receive it, rather than accepted and left unable to settle; six failWithCode(code, message) calls in the tranche validator were silently discarding their own message for the generic fallback text (failWithCode's second argument is a template arg, not a displayed string) -- switched to failWithCodeAndMessage. Removed: ShareCollectionApiResource, a direct-cash counter-collection endpoint that read m_share_account.cashier_collected, a column that has never existed in this schema -- no call site in this codebase's frontend reached it, and no entry it could have produced ever has. Co-Authored-By: Claude Opus 5 (1M context) --- ...AccountMappingFromApiJsonDeserializer.java | 8 +- .../ShareProductToGLAccountMappingHelper.java | 38 +- .../service/AccountingProcessorHelper.java | 7 + ...WritePlatformServiceJpaRepositoryImpl.java | 7 +- .../service/LedgerTransactionId.java | 157 +++++ .../journalentry/service/LedgerTxnPrefix.java | 87 +++ .../service/ShareAccountingSuppression.java | 83 +++ .../service/IncomeProfitTransferService.java | 6 +- .../api/BranchDayRegisterApiResource.java | 5 +- .../ShareAdvanceCollectionApiResource.java | 59 +- .../api/ShareCollectionApiResource.java | 439 -------------- .../api/ClerkClosureRequestApiResource.java | 4 +- ...lerkShareWithdrawalRequestApiResource.java | 486 +++++++++------ .../api/DividendClaimRequestApiResource.java | 4 +- .../SavingsPooledInterestPostingService.java | 8 +- .../shareaccounts/domain/ShareAccount.java | 23 + .../ShareAccountDataSerializer.java | 51 +- .../AdditionalShareTrancheFundingService.java | 564 +++++++++++------- .../ShareAccountSchedularServiceImpl.java | 4 +- ...WritePlatformServiceJpaRepositoryImpl.java | 78 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 14 +- .../api/VoucherVerificationApiResource.java | 90 +-- .../tenant/final-changelog-tenant.xml | 1 + .../tenant/parts/0563_ledger_txn_sequence.xml | 48 ++ 24 files changed, 1281 insertions(+), 990 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java delete mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java index fff2ccc74..d32c6b0eb 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java @@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer { if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) { + // Optional, not mandatory like the three below: it names an account for cash physically + // received at the counter for a share purchase, which every register in this bank now + // books against the account the money actually reached instead. Left absent, a product + // simply carries no such mapping, and any code path that still needs one (a genuine + // over-the-counter collection) fails with a named "GL not mapped" error rather than + // silently assuming the till. final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), element); - baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull() + baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId) .integerGreaterThanZero(); final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(), diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java index b7314f693..c17857d76 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ShareProductToGLAccountMappingHelper.java @@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccountType; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp case NONE: break; case CASH_BASED: - // asset - mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId, - CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes); + // asset -- the one optional mapping here, so unlike the three below it needs its own + // handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping). + mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes); // income mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(), @@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) { deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES); } + + /** + * Handles the one optional share-product mapping, Shares Reference, on an edit that does not change + * the accounting rule. + * + *

+ * The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so + * {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly + * changed" for them -- a null value there cannot happen without failing validation first. Shares + * Reference can legitimately be absent, and the shared merge helper (used across loan, savings and + * share products alike) only handles "present": it silently leaves an existing mapping row alone + * when the incoming value is null, rather than clearing it. Editing this one field out of an + * existing product would otherwise leave the product still carrying its old GL underneath, invisible + * on the edit form but still resolvable by any code path that looks it up directly. + */ + private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId, + final Map changes) { + final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), + element); + if (shareReferenceId != null) { + mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId, + CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes); + return; + } + final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId, + PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue()); + if (existing != null) { + this.accountMappingRepository.delete(existing); + changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index aaa9143aa..2c6e8888c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -1468,6 +1468,13 @@ public class AccountingProcessorHelper { accountMapping = paymentChannelSpecificAccountMapping; } } + // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can + // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so + // this has to fail with a clean, named error instead of NPEing on accountMapping. + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId, + CashAccountsForShares.fromInt(accountMappingTypeId).toString()); + } glAccount = accountMapping.getGlAccount(); } return glAccount; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 195582cf4..93f8d9e80 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -631,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled"); final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled"); - if (cashBasedAccountingEnabled) { + // A register that knows the transaction's real counterparty posts the entry itself, in one + // balanced pair against that account, and says so by wrapping the command in + // ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second + // entry for the same money, booked against the product's fixed Shares Reference account -- + // which is what that register is avoiding in the first place. + if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) { final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled, accrualBasedAccountingEnabled); final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java new file mode 100644 index 000000000..f07414670 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTransactionId.java @@ -0,0 +1,157 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.journalentry.service; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry. + * + *

+ * Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two + * shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key + * ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing, + * so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to + * share one transaction id and render as a single doubled voucher. Others concatenated + * {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen + * ({@code "CASHMIG-1-20260913-923935527932300"}). + * + *

+ * This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}: + * + *

+ *   SHWGL-260916-0001   the first share-withdrawal GL settlement posted on 16 Sep 2026
+ *   DIVRND-260916-0007  the seventh dividend rounding posted that day
+ * 
+ * + * The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each + * day. That makes a transaction id self-describing: a reader can tell what it was and when it happened + * without looking anything up. + * + *

+ * Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a + * counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a + * single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two + * users posting at the same instant are serialised by the database rather than handed the same number - the + * same mechanism {@code m_voucher_sequence} already uses for voucher numbers. + * + *

+ * The counter alone would still repeat if that table were ever truncated or rewound by hand, which is + * precisely the accident being defended against, so the minted id is also checked against the ledger before + * being handed back and the counter is advanced past anything already in use. That makes a duplicate + * impossible by construction rather than by convention: the id is only returned once no row holds it. + * + *

+ * Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again + * (every call site mints one, posts with it, and never derives it a second time), so old and new shapes + * coexist without anything needing to read both. + * + *

+ * Postings made by Fineract's own command path are not covered here and keep their native + * {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and + * several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most + * recently posted rows rather than assuming the text is unique. + */ +public final class LedgerTransactionId { + + /** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */ + private static final int MAX_PROBES = 50; + + private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd"); + + private LedgerTransactionId() {} + + /** + * Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}. + * + * @param prefix + * short uppercase code naming the event, from {@link LedgerTxnPrefix} + */ + public static String next(final JdbcTemplate jdbcTemplate, final String prefix) { + // Tenant timezone, not the server's - the day the counter resets on has to be the branch's + // business day, otherwise a late-evening posting lands in the wrong series. + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String datePart = today.format(YYMMDD); + + for (int probe = 0; probe < MAX_PROBES; probe++) { + final int serial = nextSerial(jdbcTemplate, prefix, today); + final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial); + if (!isTaken(jdbcTemplate, candidate)) { + return candidate; + } + // Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks + // it forward until it clears whatever is already posted, which self-heals the counter. + } + throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES + + " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today."); + } + + /** + * Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}. + * + *

+ * Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that + * stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that + * started them, so the id has to come out the same on the second pass - a counter would hand the + * remainder a different id and split one posting across two transactions. This builds the id from + * what identifies the run instead: the same prefix, date and discriminator always produce the same + * string, and no counter is consumed. + * + *

+ * Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the + * product being posted, say), which is what makes the id reproducible in the first place. Use + * {@link #next} for everything else - a posting that happens once wants a counter, not a formula. + * The {@code P} marks the tail as a discriminator so it is not misread as a serial. + * + * @param date + * the date the posting belongs to, which is not always today - interest can be posted + * for an earlier day + * @param discriminator + * what makes this run distinct from another on the same date, e.g. the product id + */ + public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) { + return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator); + } + + /** + * Reserves the next counter value for one (prefix, day) series. + * + * The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and + * increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed. + */ + private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) { + return jdbcTemplate.queryForObject(""" + INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number) + VALUES (?,?,1) + ON CONFLICT (prefix, series_date) + DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1 + RETURNING last_number + """, Integer.class, prefix, Date.valueOf(day)); + } + + private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) { + final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)", + Boolean.class, transactionId); + return Boolean.TRUE.equals(taken); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java new file mode 100644 index 000000000..d31549dbb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/LedgerTxnPrefix.java @@ -0,0 +1,87 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.journalentry.service; + +/** + * The event codes that open a transaction id minted by {@link LedgerTransactionId}. + * + *

+ * They live together in one place so a reader can decode any transaction id on a screen from a single list, + * and so no two features can drift into naming the same event differently - which had already happened: + * dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from + * another, for the same event. + * + *

+ * Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of + * the audit trail once it has been posted, so treat these as append-only: add a new one for a new event + * rather than renaming an existing one, which would leave already-posted entries speaking a language nothing + * else uses any more. + */ +public final class LedgerTxnPrefix { + + private LedgerTxnPrefix() {} + + // A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the + // native redeem command already minted ("SH" + share transaction id), because its debit leg is + // that command's own and the two have to group as one balanced entry. + + // ── Share tranche funding ───────────────────────────────────────────────── + /** Additional share tranche: reversal of the stock reference posting. */ + public static final String SHARE_TRANCHE_REFERENCE = "SHTREF"; + /** Additional share tranche funded from the member's savings account. */ + public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV"; + + // ── Share collection and advances ───────────────────────────────────────── + /** Share application money collected. */ + public static final String SHARE_COLLECTION = "SHCOL"; + /** Share advance collected against a future allotment. */ + public static final String SHARE_ADVANCE = "SHADV"; + /** Share advance applied to an allotment. */ + public static final String SHARE_ADVANCE_APPLIED = "SHAPP"; + + // ── Dividend ────────────────────────────────────────────────────────────── + /** Dividend claim paid out to a member. */ + public static final String DIVIDEND_CLAIM = "DIVCLM"; + /** Dividend declaration posted. */ + public static final String DIVIDEND_DECLARED = "DIVDCL"; + /** Dividend declaration approved. */ + public static final String DIVIDEND_APPROVED = "DIVAPR"; + /** Dividend payout closed off. */ + public static final String DIVIDEND_CLOSED = "DIVCLS"; + /** Rounding difference on a dividend payout. */ + public static final String DIVIDEND_ROUNDING = "DIVRND"; + /** Unclaimed dividend forfeited. */ + public static final String DIVIDEND_FORFEIT = "DIVFRF"; + + // ── Branch day and cash ─────────────────────────────────────────────────── + /** Opening cash migrated into the branch day register. */ + public static final String CASH_MIGRATION = "CSHMIG"; + /** Rounding difference on a clerk's day closure. */ + public static final String CLOSURE_ROUNDING = "CLSRND"; + + // ── Year end ────────────────────────────────────────────────────────────── + /** Year-end transfer of income into the profit account. */ + public static final String YEAR_END_PROFIT = "YEPFT"; + /** Year-end closure of an income or expense account. */ + public static final String YEAR_END_CLOSE = "YECLS"; + + // ── Savings ─────────────────────────────────────────────────────────────── + /** Pooled savings interest posting for one product and date. */ + public static final String SAVINGS_INTEREST_POOLED = "SAVINT"; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java new file mode 100644 index 000000000..2efbd1239 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/ShareAccountingSuppression.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.journalentry.service; + +import java.util.function.Supplier; + +/** + * Lets a register that posts a share transaction's accounting itself tell the platform not to post its + * own. + * + *

Why this exists

+ * + *

+ * Fineract books every share transaction against a fixed "Shares Reference" account taken from the + * product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money + * actually did. That account is a placeholder for a counterparty the platform does not know, and in a + * real core banking ledger there is no such thing — a redemption paid in cash credits the cash + * account, and one settled by transfer credits the account it was transferred to. Naming a single + * asset account for both leaves every redemption sitting against a GL the money never touched, which + * then has to be corrected by hand. + * + *

+ * The governed clerk registers already know the real counterparty, because the clerk keyed it: the + * linked savings account, or the GL picked on the transfer's credit row. They post the redemption + * themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform + * post its version first and then reverse the half that was wrong, which left a dead Cr Shares + * Reference row behind on every redemption and made the ledger read as though cash had moved. + * + *

Why a flag and not the product's accounting rule

+ * + *

+ * Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE}) + * would silence the platform everywhere, including the share account screen's own Redeem action, which + * no register covers — those redemptions would post nothing at all and go unnoticed, because that + * branch fails silently by design. Scoping the suppression to the one call that is about to post its + * own entry leaves every other path exactly as it is. + * + *

+ * A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing + * it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to + * hang the flag on, and nesting must not switch the suppression off early for an outer caller. + */ +public final class ShareAccountingSuppression { + + private static final ThreadLocal CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE); + + private ShareAccountingSuppression() {} + + /** + * Runs {@code command} with the platform's own share journal entries suppressed, for a caller that + * posts the transaction's accounting itself. + */ + public static T withOwnShareEntries(final Supplier command) { + final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get(); + CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE); + try { + return command.get(); + } finally { + CALLER_POSTS_SHARE_ENTRIES.set(previous); + } + } + + /** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */ + public static boolean shouldPostShareEntries() { + return !CALLER_POSTS_SHARE_ENTRIES.get(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java index 5bb6f385d..6873aa9eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/yearend/service/IncomeProfitTransferService.java @@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.dao.EmptyResultDataAccessException; @@ -117,7 +119,7 @@ public class IncomeProfitTransferService { final String currencyCode = resolveCurrency(incomeGlId); final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final LocalDate postingDate = range[1]; - final String txnId = "YEPROFIT-" + System.nanoTime(); + final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT); final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit" + (note != null ? " | " + note : ""); @@ -210,7 +212,7 @@ public class IncomeProfitTransferService { final String currencyCode = resolveCurrency(glId); final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final LocalDate postingDate = range[1]; - final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime(); + final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE); final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit" + (note != null ? " | " + note : ""); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 701e9e4e8..cbfc2ee8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -38,6 +38,8 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -513,8 +515,7 @@ public class BranchDayRegisterApiResource { final Long contraId = contraIds.get(0); final String currencyCode = jdbcTemplate.queryForObject( "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); - final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-" - + System.nanoTime(); + final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION); insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser); insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index b45a7fe52..c0c72ff3b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -150,7 +152,7 @@ public class ShareAdvanceCollectionApiResource { final long userId = currentUser.getId(); final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHADV-" + System.nanoTime(); + final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE); final String receiptNo = nextReceiptNo(); final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : ""); @@ -472,14 +474,26 @@ public class ShareAdvanceCollectionApiResource { final long userId = currentUser.getId(); final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); final LocalDate entryDate = parseDate(getString(json, "transactionDate")); - final String txnId = "SHAPP-" + System.nanoTime(); + final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.SHARE_ADVANCE_APPLIED); final String accountNo = (String) acct.get("account_no"); - // Dr Sundry for the share-side receipts, including any over-payment on them — the liability - // those receipts created is cleared in full. A verified fee receipt is deliberately excluded: - // it was never in Sundry, so nothing here should debit Sundry for it. - insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); + // Dr whichever account each receipt's money is actually sitting in, receipt by receipt, + // including any over-payment on them — the liability those receipts created is cleared in + // full. A verified fee receipt is deliberately excluded: its money was never held for the + // shares, so nothing here should move it. + // + // Read back from the collection's own posting rather than resolved from the product's Sundry + // mapping. The cashier does not always deposit into Sundry -- an entry fee goes straight to + // its income account, and instalments taken on different days can land in different accounts + // -- so debiting the mapped Sundry account for every receipt took money out of an account + // that had never received it, and left the account that had holding the balance for ever. + // Debiting each receipt's own account is also what the clerk sees: the GL picked against the + // voucher on the apply screen is the account being cleared here. + for (final Map row : collections) { + final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense); + insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now, + currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo); + } // Cr Share Capital for the share value. insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, @@ -809,6 +823,37 @@ public class ShareAdvanceCollectionApiResource { } } + /** + * The account a receipt's money is actually sitting in, read back from the posting that put it + * there — the credit leg of the collection's own journal transaction. + * + *

+ * Whoever took the money decided where it went: this register's own collection credits Sundry, a + * cashier deposit credits whichever account the cashier posted against, and an entry fee usually + * goes straight to fee income. Reading the account back is therefore the only way to know it, and + * it matches what the clerk picks against the voucher on the apply screen. + * + *

+ * Falls back to the product's mapped Sundry account for a collection with no posting recorded + * against it, which is where anything taken before this register started stamping its transaction + * id was held. + * + *

+ * Scoped to the live credit leg, newest first: a transaction id is only as unique as whatever + * minted it, and picking the most recent match keeps this correct even if one is ever reused. + */ + private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) { + if (glTransactionId == null || glTransactionId.isBlank()) { + return fallbackGlId; + } + final List rows = jdbcTemplate.queryForList(""" + SELECT account_id FROM acc_gl_journal_entry + WHERE transaction_id = ? AND type_enum = ? AND reversed = false + ORDER BY id DESC LIMIT 1 + """, Long.class, glTransactionId, CREDIT); + return rows.isEmpty() ? fallbackGlId : rows.get(0); + } + private Long requireGl(final Long productId, final int financialAccountType, final String label) { final Long glId = resolveGl(productId, financialAccountType); if (glId == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java deleted file mode 100644 index 1ee09f3a3..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareCollectionApiResource.java +++ /dev/null @@ -1,439 +0,0 @@ -package org.apache.fineract.portfolio.cashtransfer.api; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -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 jakarta.ws.rs.core.Response; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeParseException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; -import org.apache.fineract.infrastructure.core.service.DateUtils; - -/** - * Cashier Share Collection API — POST /v1/cashier/sharecollection - * - * Records cash received from a member for share purchase and posts GL entries: - * Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount - * Cr SHARES_SUSPENSE (Share Sundry) = shareAmount - * Cr INCOME_FROM_FEES = chargeAmount (if > 0) - * - * On account activation Fineract posts: - * Dr SHARES_SUSPENSE → Cr SHARES_EQUITY - * Activation skips charge GL if is_paid_derived = true (set here). - */ -@Path("/v1/cashier/sharecollection") -@Component -@RequiredArgsConstructor -@Consumes({ MediaType.APPLICATION_JSON }) -@Produces({ MediaType.APPLICATION_JSON }) -public class ShareCollectionApiResource { - - // ── acc_gl_journal_entry.type_enum ───────────────────────────────────── - // Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr) - // Opposite of intuition — verified from Fineract AccountingProcessorHelper. - private static final int DEBIT = 2; - private static final int CREDIT = 1; - - // ── CashAccountsForShares financial_account_type ─────────────────────── - private static final int FA_SHARES_REFERENCE = 1; - private static final int FA_SHARES_SUSPENSE = 2; - private static final int FA_INCOME_FROM_FEES = 3; - - // ── acc_gl_journal_entry.entity_type_enum ───────────────────────────── - // Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt() - // which only handles values 1-5 and returns null for unknown values. - - // ── acc_product_mapping.product_type for shares ──────────────────────── - private static final int PRODUCT_TYPE_SHARES = 4; - - // ── m_share_account.status_enum ─────────────────────────────────────── - private static final int STATUS_SUBMITTED_PENDING = 100; - - // ── Maximum days a collection record is considered valid ────────────── - private static final int MAX_COLLECTION_AGE_DAYS = 30; - - private final PlatformSecurityContext context; - private final JdbcTemplate jdbcTemplate; - private final Gson gson = new Gson(); - - // ══════════════════════════════════════════════════════════════════════ - // POST — Collect cash for a share account - // ══════════════════════════════════════════════════════════════════════ - @POST - @Transactional - public Response collect(final String body) { - - final AppUser currentUser = this.context.authenticatedUser(); - currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); - - // ── Parse request ────────────────────────────────────────────────── - if (body == null || body.isBlank()) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.request.empty", - "Request body is required."); - } - final JsonObject json; - try { - json = JsonParser.parseString(body).getAsJsonObject(); - } catch (Exception e) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.request.invalid", - "Request body must be valid JSON."); - } - - final Long shareAccountId = getLong(json, "shareAccountId"); - final String transactionDateStr = getString(json, "transactionDate"); - final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount"); - final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount"); - final String note = sanitiseNote(getString(json, "note")); - - // ── Validate inputs ──────────────────────────────────────────────── - if (shareAccountId == null) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.accountid.required", - "shareAccountId is required."); - } - if (shareAmount.compareTo(BigDecimal.ZERO) < 0) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.shareamount.negative", - "shareAmount cannot be negative."); - } - final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0 - ? chargeAmountRaw : BigDecimal.ZERO; - final BigDecimal totalAmount = shareAmount.add(safeCharge); - if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.total.zero", - "Total collection amount must be greater than zero."); - } - - final LocalDate entryDate; - try { - entryDate = transactionDateStr != null - ? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - } catch (DateTimeParseException e) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.date.invalid", - "transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr); - } - - // ── Load account + client details ────────────────────────────────── - final Map acct = loadAccount(shareAccountId); - - // ── Account must be in 'Submitted and pending approval' ─────────── - final int statusEnum = toInt(acct.get("status_enum")); - if (statusEnum != STATUS_SUBMITTED_PENDING) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.account.invalid.status", - "Cash collection is only allowed for accounts in 'Submitted and pending approval' " - + "status. Account " + acct.get("account_no") + " has status code " + statusEnum + "."); - } - - // ── Prevent double collection ────────────────────────────────────── - if (Boolean.TRUE.equals(acct.get("cashier_collected"))) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.already.collected", - "Cash has already been collected for share account " - + acct.get("account_no") + ". Re-collection is not allowed."); - } - - final Long officeId = toLong(acct.get("office_id")); - final Long productId = toLong(acct.get("product_id")); - final String currencyCode = (String) acct.get("currency_code"); - final String clientName = (String) acct.get("client_name"); - final String accountNo = (String) acct.get("account_no"); - - // ── Resolve GL accounts from product mapping ─────────────────────── - final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE); - final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE); - final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES); - - if (glReference == null) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.gl.reference.notmapped", - "Share Reference (Cash at Vault) GL is not mapped for product " + productId - + ". Configure Share Product accounting mappings first."); - } - if (glSuspense == null) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.gl.suspense.notmapped", - "Share Suspense GL is not mapped for product " + productId - + ". Configure Share Product accounting mappings first."); - } - if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.gl.income.notmapped", - "Income from Fees GL is not mapped for product " + productId - + " but charge amount " + safeCharge + " is present. " - + "Configure Share Product accounting mappings first."); - } - - // ── Generate unique transaction ID ───────────────────────────────── - final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis(); - final long userId = currentUser.getId(); - final String collectorName = currentUser.getUsername(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - - // ── Post GL journal entries ──────────────────────────────────────── - // Dr Cash at Vault (SHARES_REFERENCE) = totalAmount - insertEntry(officeId, glReference, DEBIT, totalAmount, txnId, - entryDate, shareAccountId, userId, now, currencyCode, - "Share collection received for " + accountNo - + (note != null ? " | " + note : "")); - - // Cr Share Sundry (SHARES_SUSPENSE) = shareAmount - insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId, - entryDate, shareAccountId, userId, now, currencyCode, - "Share amount held in suspense - " + accountNo); - - // Cr Income from Fees = chargeAmount (only if > 0) - if (safeCharge.compareTo(BigDecimal.ZERO) > 0) { - insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId, - entryDate, shareAccountId, userId, now, currencyCode, - "Charge collected upfront for " + accountNo); - - // Mark charges paid → activation will skip duplicate charge GL entry - jdbcTemplate.update( - "UPDATE m_share_account_charge " - + "SET is_paid_derived = true, " - + " amount_paid_derived = amount, " - + " amount_outstanding_derived = 0 " - + "WHERE account_id = ? AND is_active = true AND is_paid_derived = false", - shareAccountId); - } - - // ── Flip cashier_collected flag ──────────────────────────────────── - jdbcTemplate.update( - "UPDATE m_share_account SET cashier_collected = true WHERE id = ?", - shareAccountId); - - // ── Insert audit / validation record ────────────────────────────── - jdbcTemplate.update( - "INSERT INTO m_share_cashier_collection " - + "(share_account_id, collected_by_user, collected_by_name, collection_date, " - + " share_amount, charge_amount, total_amount, gl_transaction_id, " - + " office_id, currency_code, note) " - + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", - shareAccountId, userId, collectorName, - java.sql.Date.valueOf(entryDate), - shareAmount, safeCharge, totalAmount, - txnId, officeId, currencyCode, note); - - // ── Build GL entries summary for response ────────────────────────── - final List> glEntries = new ArrayList<>(); - glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode)); - glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode)); - if (safeCharge.compareTo(BigDecimal.ZERO) > 0) { - glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode)); - } - - // ── Response ─────────────────────────────────────────────────────── - final Map resp = new LinkedHashMap<>(); - resp.put("transactionId", txnId); - resp.put("shareAccountId", shareAccountId); - resp.put("accountNo", accountNo); - resp.put("clientName", clientName); - resp.put("collectionDate", entryDate.toString()); - resp.put("collectedBy", collectorName); - resp.put("shareAmount", shareAmount); - resp.put("chargeAmount", safeCharge); - resp.put("totalAmount", totalAmount); - resp.put("currencyCode", currencyCode); - resp.put("glEntries", glEntries); - - return Response.ok(gson.toJson(resp)).build(); - } - - // ══════════════════════════════════════════════════════════════════════ - // GET — Check collection status for a share account - // ══════════════════════════════════════════════════════════════════════ - @GET - @Path("/{shareAccountId}") - public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) { - this.context.authenticatedUser(); - - try { - final Map rec = jdbcTemplate.queryForMap( - "SELECT scc.share_account_id, sa.account_no, " - + " mc.display_name AS client_name, " - + " scc.collected_by_name, scc.collection_date, " - + " scc.share_amount, scc.charge_amount, scc.total_amount, " - + " scc.gl_transaction_id, scc.currency_code, scc.note " - + "FROM m_share_cashier_collection scc " - + "JOIN m_share_account sa ON sa.id = scc.share_account_id " - + "JOIN m_client mc ON mc.id = sa.client_id " - + "WHERE scc.share_account_id = ?", - shareAccountId); - - final Map resp = new LinkedHashMap<>(); - resp.put("collected", true); - resp.put("shareAccountId", shareAccountId); - resp.put("accountNo", rec.get("account_no")); - resp.put("clientName", rec.get("client_name")); - resp.put("collectedBy", rec.get("collected_by_name")); - resp.put("collectionDate", rec.get("collection_date") != null - ? rec.get("collection_date").toString() : null); - resp.put("shareAmount", rec.get("share_amount")); - resp.put("chargeAmount", rec.get("charge_amount")); - resp.put("totalAmount", rec.get("total_amount")); - resp.put("transactionId", rec.get("gl_transaction_id")); - resp.put("currencyCode", rec.get("currency_code")); - resp.put("note", rec.get("note")); - return Response.ok(gson.toJson(resp)).build(); - - } catch (EmptyResultDataAccessException e) { - final Map resp = new LinkedHashMap<>(); - resp.put("collected", false); - resp.put("shareAccountId", shareAccountId); - return Response.ok(gson.toJson(resp)).build(); - } - } - - // ══════════════════════════════════════════════════════════════════════ - // Private helpers - // ══════════════════════════════════════════════════════════════════════ - - private Map loadAccount(final Long shareAccountId) { - try { - return jdbcTemplate.queryForMap( - "SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, " - + " sa.cashier_collected, sa.currency_code, " - + " mc.office_id, mc.display_name AS client_name " - + "FROM m_share_account sa " - + "JOIN m_client mc ON mc.id = sa.client_id " - + "WHERE sa.id = ?", - shareAccountId); - } catch (EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.account.notfound", - "Share account with id " + shareAccountId + " does not exist."); - } - } - - private Long resolveGl(final Long productId, final int financialAccountType) { - try { - return jdbcTemplate.queryForObject( - "SELECT gl_account_id FROM acc_product_mapping " - + "WHERE product_id = ? AND product_type = ? AND financial_account_type = ?", - Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); - } catch (IncorrectResultSizeDataAccessException e) { - // covers both EmptyResultDataAccessException (0 rows) and multiple rows - return null; - } - } - - private String resolveGlName(final Long glAccountId) { - if (glAccountId == null) return "Unknown GL"; - try { - return jdbcTemplate.queryForObject( - "SELECT name FROM acc_gl_account WHERE id = ?", - String.class, glAccountId); - } catch (EmptyResultDataAccessException e) { - return "GL-" + glAccountId; - } - } - - private void insertEntry(final Long officeId, final Long glAccountId, - final int typeEnum, final BigDecimal amount, final String txnId, - final LocalDate entryDate, final Long shareAccountId, - final long userId, final LocalDateTime now, final String currencyCode, - final String description) { - GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); - jdbcTemplate.update( - "INSERT INTO acc_gl_journal_entry " - + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " - + " entry_date, type_enum, amount, description, entity_id, " - + " created_by, last_modified_by, created_date, lastmodified_date, " - + " is_running_balance_calculated, office_running_balance, " - + " organization_running_balance, submitted_on_date, transaction_date, " - + " created_on_utc, last_modified_on_utc) " - + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", - glAccountId, officeId, currencyCode, txnId, - java.sql.Date.valueOf(entryDate), typeEnum, amount, description, - shareAccountId, // entity_id — links entry to the share account - userId, userId, now, now, - java.sql.Date.valueOf(entryDate), - java.sql.Date.valueOf(entryDate), - toUtc(now), - toUtc(now)); - } - - /** - * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. - * - * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as - * 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in - * the tenant's own zone first, which is the zone {@code now} was taken in. - */ - private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { - return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() - .withOffsetSameInstant(ZoneOffset.UTC); - } - - private Map glEntryMap(final String account, final String type, - final BigDecimal amount, final String currency) { - final Map m = new LinkedHashMap<>(); - m.put("account", account); - m.put("type", type); - m.put("amount", amount); - m.put("currency", currency); - return m; - } - - private String sanitiseNote(final String raw) { - if (raw == null || raw.isBlank()) return null; - final String trimmed = raw.trim(); - return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed; - } - - private Long toLong(final Object o) { - return o instanceof Number ? ((Number) o).longValue() : null; - } - - private int toInt(final Object o) { - return o instanceof Number ? ((Number) o).intValue() : 0; - } - - private Long getLong(final JsonObject json, final String key) { - return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null; - } - - private BigDecimal getBigDecimal(final JsonObject json, final String key, - final String fieldLabel) { - if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO; - try { - return json.get(key).getAsBigDecimal(); - } catch (NumberFormatException e) { - throw new PlatformDataIntegrityException( - "error.msg.sharecollection.field.invalid.number", - "Field '" + fieldLabel + "' must be a valid number."); - } - } - - private String getString(final JsonObject json, final String key) { - return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index e8f286fb1..5495eee7b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -50,6 +50,8 @@ import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -856,7 +858,7 @@ public class ClerkClosureRequestApiResource { // Same business date as the closure this is adjusting, so the two never land on // different days in the day book. final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime(); + final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CLOSURE_ROUNDING); final Long cashGlId = ((Number) cashGl.get("id")).longValue(); // type_enum: 2 = DEBIT, 1 = CREDIT. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index e2db00cf3..dc9690edf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -38,18 +38,23 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; import java.sql.Date; +import java.sql.PreparedStatement; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.ShareAccountingSuppression; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -64,6 +69,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -104,22 +111,25 @@ public class ClerkShareWithdrawalRequestApiResource { "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode", "settlement_gl_account_id" }; - // "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of - // the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a - // pre-settlement_payment_type_id-column legacy request still resolves to a real payment type - // instead of silently posting its settlement with none at all. - private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER"; - - // ── Sundry pass-through for the redemption settlement ─────────────────── + // ── GL posting for the redemption and its settlement ──────────────────── // Fineract convention: 1 = CREDIT, 2 = DEBIT. private static final int CREDIT = 1; private static final int DEBIT = 2; + // m_savings_account.status_enum: 300 = active. + private static final int SAVINGS_STATUS_ACTIVE = 300; + // acc_product_mapping.financial_account_type for share products. - private static final int FA_SHARES_REFERENCE = 1; private static final int FA_SHARES_SUSPENSE = 2; + private static final int FA_INCOME_FROM_FEES = 3; + private static final int FA_SHARES_EQUITY = 4; private static final int PRODUCT_TYPE_SHARES = 4; + // What Fineract's own bridge prefixes a share transaction's journal entries with + // (AccountingProcessorHelper.SHARE_TRANSACTION_IDENTIFIER). Reused so an entry this class posts + // in the platform's place groups under the id everything else already looks for. + private static final String SHARE_TXN_ID_PREFIX = "SH"; + // acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. private static final int PRODUCT_TYPE_SAVINGS = 2; private static final int FA_SAVINGS_CONTROL = 2; @@ -146,8 +156,8 @@ public class ClerkShareWithdrawalRequestApiResource { final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root); // 'SAVINGS' (default, and what every request submitted before this field existed carries) // settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to - // whichever GL account the clerk picked on the CR row instead (a Sundry-style settlement that - // never touches a member's own account) — see postGlSettlement() in approve(). 'NONE' is not + // whichever GL account the clerk picked on the CR row instead (a settlement that never + // touches a member's own account) — see the settlement branch in approve(). 'NONE' is not // accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own // chosen GL account turns out to be the redemption's own Sundry account. final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root); @@ -189,10 +199,10 @@ public class ClerkShareWithdrawalRequestApiResource { throw apiError(400, "Settlement GL Account is required for a GL settlement"); } GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId); - // The redemption's own leg 1 (routeRedeemIntoSundry(), unconditional) always credits the - // product's own Sundry GL -- picking that same account as the settlement target here - // would make postGlSettlement() debit and credit the identical account, a self-cancelling - // entry with no real movement that no real CBS ledger allows. Rather than reject this + // A redemption that settles nowhere further credits the product's own Sundry GL -- + // picking that same account as the settlement target here would name Sundry as both the + // source and the destination of the one entry, a self-cancelling posting with no real + // movement that no real CBS ledger allows. Rather than reject this // outright, treat it as what the clerk actually means: "stop here, the value stays in // Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher // for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone. @@ -524,8 +534,8 @@ public class ClerkShareWithdrawalRequestApiResource { // Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a // plain savings deposit command, which resolves its GL leg from the savings product's own // Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement - // never goes near that mapping at all; postGlSettlement() resolves Sundry and the clerk's - // chosen GL account directly, so this check is reported not-applicable instead. + // never goes near that mapping at all; it credits the clerk's chosen GL account directly, + // so this check is reported not-applicable instead. if (glSettlementMode) { final Object glAccountIdObj = record.get("settlement_gl_account_id"); final boolean settlementGlPresent = glAccountIdObj != null; @@ -709,7 +719,13 @@ public class ClerkShareWithdrawalRequestApiResource { final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares") .withJson(payload.toString()).build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // The command is run for what it does to the share account -- the holding it reduces and the + // transaction it records -- not for its accounting. Left to itself it would credit the + // product's fixed "Shares Reference" account, which is a stand-in for a counterparty it has + // no way of knowing; this register does know it, because the clerk keyed it, and posts the + // entry itself below against the account the money actually reached. + final CommandProcessingResult result = ShareAccountingSuppression + .withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); // redeemShares() puts the newly-created share transaction's id under "requestedShares" in // the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id // instead. Reading getResourceId() here would silently look up the wrong row downstream. @@ -717,52 +733,65 @@ public class ClerkShareWithdrawalRequestApiResource { final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null; final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null; - // Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into - // Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or - // leaves share capital in this codebase passes through Sundry, and leaving redeem on its own - // "give cash back over the counter" assumption breaks that the moment the settlement below - // lands on a different GL than this leg did. - if (shareTransactionId != null) { - routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)), - currentUser.getId()); - } - - // Settlement: within this SAME approval transaction, immediately credit the redemption value - // to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly, - // not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally - // land on the same account the leg above credited. See creditLinkedSavingsAccount(). - final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id"); - final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number - ? ((Number) storedSettlementPaymentTypeId).longValue() - : null; + // Settlement: within this SAME approval transaction, the redemption value is booked to + // wherever the clerk said it should go. Where that destination is known here -- a linked + // savings account, or a GL the clerk picked -- the native redeem posting's own Cr Shares + // Reference (Cash at Vault) leg is redirected straight onto it below, so the ledger reads as + // the single entry the clerk actually keyed: Dr Share Capital / Cr . final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS"); final Settlement settlement; final GlSettlement glSettlement; final String settlementNote; + // Where the redemption credit lands, and what to call it on the journal line. Resolved per + // mode, then applied once below. + final Long destinationGl; + final String destinationNarration; if ("NONE".equals(settlementMode)) { - // The clerk deliberately chose to stop at leg 1 -- the redemption value stays in Share - // Sundry (already credited by routeRedeemIntoSundry() above), with no second leg routing - // it anywhere else. raiseSundryOnlyVoucher() below gives that leg its own standalone + // The clerk deliberately chose to settle nowhere further: Share Sundry is the + // destination, not a staging post, and the value genuinely rests there until some later + // entry moves it. raiseSundryOnlyVoucher() below gives that entry its own standalone // voucher instead of leaving it visible only as "Also posted" under some other voucher. settlement = null; glSettlement = null; + final Map noneAcct = loadShareAccountGlContext(shareAccountId); + destinationGl = requireShareGl(toLong(noneAcct.get("product_id")), FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + destinationNarration = "Share redemption held in Sundry pending settlement"; settlementNote = "Redemption booked to Share Sundry — no further settlement leg."; } else if ("GL".equals(settlementMode)) { settlement = null; final Object glAccountIdObj = record.get("settlement_gl_account_id"); final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null; - glSettlement = postGlSettlement(shareAccountId, shareTransactionId, requestedDateObj.toString(), settlementGlAccountId); + glSettlement = resolveGlSettlement(shareTransactionId, settlementGlAccountId); + destinationGl = glSettlement != null ? glSettlement.glAccountId() : null; + destinationNarration = "Share redemption settled to GL account"; settlementNote = glSettlement != null ? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId()) : "Share transaction not found — GL settlement could not be booked."; } else { glSettlement = null; - settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber, - settlementPaymentTypeId); + settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber); + destinationGl = settlement != null ? settlement.savingsControlGlId() : null; + destinationNarration = "Share redemption settled to savings"; settlementNote = settlement != null ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")" : "No linked savings account on this share account — settlement must be completed manually."; } + + // One posting, not two: the native redeem's Cr Cash at Vault leg is replaced by a credit to + // the destination itself, under the native posting's own transaction_id. The ledger then + // holds exactly the entry the clerk keyed -- Dr Share Capital / Cr -- instead + // of routing it through Sundry and back out again, which left two transaction groups and a + // pass-through balance that always netted to zero. + // + // Where no destination could be resolved (no linked savings account, or the share + // transaction could not be read back), the native Cr leg is left exactly as Fineract posted + // it rather than being reversed with nothing put in its place, which would leave the Dr + // Share Capital leg standing alone and the entry out of balance. + if (shareTransactionId != null && destinationGl != null) { + postRedemptionEntry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)), + currentUser.getId(), destinationGl, destinationNarration); + } + final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), settlementNote); @@ -791,8 +820,8 @@ public class ClerkShareWithdrawalRequestApiResource { // redemption settled into a member's savings account left nothing on the voucher register to // read it back from. The GL entries were always written — it is the slip that was missing. final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser) - : "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareAccountId, record, glSettlement, currentUser) - : raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser); + : "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareTransactionId, record, glSettlement, currentUser) + : raiseShareWithdrawalVoucher(id, shareAccountId, shareTransactionId, record, settlement, currentUser); final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); @@ -836,7 +865,11 @@ public class ClerkShareWithdrawalRequestApiResource { closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request."); final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close") .withJson(closePayload.toString()).build(); - commandsSourceWritePlatformService.logCommandSource(closeCommand); + // Suppressed for the same reason as the redemption above: anything this posts would go + // against the product's fixed "Shares Reference" account. There is nothing left for it to + // post in any case -- this runs only once the holding has reached zero, so the redemption + // that emptied it has already been booked, in full, against the account the money reached. + ShareAccountingSuppression.withOwnShareEntries(() -> commandsSourceWritePlatformService.logCommandSource(closeCommand)); return true; } @@ -853,17 +886,18 @@ public class ClerkShareWithdrawalRequestApiResource { * savings credit have all already succeeded in this transaction, and losing the slip is worth * less than rolling back a member's money. */ - private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map record, - final Settlement settlement, final AppUser currentUser) { + private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Long shareTransactionId, + final Map record, final Settlement settlement, final AppUser currentUser) { if (!voucherService.shouldRaiseVoucher() || settlement == null || settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) { return null; } try { - final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, - shareAccountId); - final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE); + final Long glShare = redeemDebitGlOf(shareTransactionId); + if (glShare == null) { + return null; + } final Long savingsAccountId = jdbcTemplate .queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream() @@ -875,7 +909,7 @@ public class ClerkShareWithdrawalRequestApiResource { .stream().findFirst().orElse(null); final List lines = new ArrayList<>(); - lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), settlement.amount())); + lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), settlement.amount())); // This leg is the member's own savings account receiving the redemption proceeds, not a // second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL // account here, mismatched against the member's own account number on the same line. @@ -905,24 +939,52 @@ public class ClerkShareWithdrawalRequestApiResource { } } - private record Settlement(BigDecimal amount, Long savingsTransactionId) {} + private record Settlement(BigDecimal amount, Long savingsTransactionId, Long savingsControlGlId) {} private record GlSettlement(BigDecimal amount, Long glAccountId) {} /** - * Books the redemption value to whichever GL account the clerk picked on the CR row of the Fund - * Transfer, routed Dr Share Sundry / Cr that GL account — the GL counterpart to - * {@link #creditLinkedSavingsAccount} for a settlement that never touches a member's own account - * (e.g. crediting a "DD Payable" holding account, so a second, independent manual entry can later - * move the value out to the actual demand draft). Posted in full at approval time, exactly like - * the Sundry leg before it. + * The GL account the native redeem command debited — the share capital side of the redemption, + * which is what a voucher's Dr line has to name now that the credit goes straight to its + * destination and no Sundry leg stands between them. + * + *

+ * Read back from what was actually posted rather than resolved from a mapping constant: this + * class has never posted that leg itself (only Fineract's own redeem command does), so reading it + * back is correct whichever GL the product's Shares Equity mapping happens to point at. + * + *

+ * Scoped to the live Dr row for this share transaction, newest first: the "SH"+id transaction_id + * text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a + * reset id sequence in a dev environment can violate. + */ + private Long redeemDebitGlOf(final Long shareTransactionId) { + if (shareTransactionId == null) { + return null; + } + return jdbcTemplate.queryForList(""" + SELECT account_id FROM acc_gl_journal_entry + WHERE share_transaction_id = ? AND type_enum = ? AND reversed = false + ORDER BY id DESC LIMIT 1 + """, Long.class, shareTransactionId, DEBIT).stream().findFirst().orElse(null); + } + + /** + * Reads back the amount to settle and names the GL account the clerk picked on the CR row of the + * Fund Transfer — the GL counterpart to {@link #creditLinkedSavingsAccount} for a settlement that + * never touches a member's own account (e.g. crediting a "DD Payable" holding account, so a + * second, independent manual entry can later move the value out to the actual demand draft). + * + *

+ * Nothing is posted here. The caller redirects the native redeem posting's own credit leg onto + * this account, so the redemption reads as the one entry the clerk keyed — Dr Share Capital / Cr + * that GL — rather than as a Sundry hop that nets to zero. * *

* Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded — * the redemption value posts to the paisa, same as the linked-savings path. */ - private GlSettlement postGlSettlement(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr, - final Long settlementGlAccountId) { + private GlSettlement resolveGlSettlement(final Long shareTransactionId, final Long settlementGlAccountId) { if (shareTransactionId == null) { return null; } @@ -935,55 +997,36 @@ public class ClerkShareWithdrawalRequestApiResource { if (row == null) { return null; } - final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount"); - - final Map acct = loadShareAccountGlContext(shareAccountId); - final Long productId = toLong(acct.get("product_id")); - final Long officeId = toLong(acct.get("office_id")); - final String currencyCode = (String) acct.get("currency_code"); - final String accountNo = (String) acct.get("account_no"); - - final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); - - final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10)); - final long userId = context.authenticatedUser().getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHRGLSTL-" + System.nanoTime(); - - insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share redemption settled to GL account | " + accountNo); - insertEntry(officeId, settlementGlAccountId, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share redemption settlement credited to GL account | " + accountNo); - - return new GlSettlement(settlementAmount, settlementGlAccountId); + return new GlSettlement((BigDecimal) row.get("settlementAmount"), settlementGlAccountId); } /** - * The voucher for an approved GL redemption: Dr Share Sundry, Cr the clerk-chosen settlement GL - * account — the same two accounts {@link #postGlSettlement} actually posted to, both named and - * coded from the ledger since both are real GL accounts here (unlike the linked-savings path, - * there is no member-account leg on this side at all). + * The voucher for an approved GL redemption: Dr Share Capital, Cr the clerk-chosen settlement + * GL account — the same two accounts the redemption actually posted to, both named and coded + * from the ledger since both are real GL accounts here (unlike the linked-savings path, there is + * no member-account leg on this side at all). */ - private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map record, + private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareTransactionId, final Map record, final GlSettlement glSettlement, final AppUser currentUser) { if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null || glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) { return null; } try { - final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, - shareAccountId); - final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE); + final Long glShare = redeemDebitGlOf(shareTransactionId); final Long glTarget = glSettlement.glAccountId(); + if (glShare == null) { + return null; + } final List lines = new ArrayList<>(); - lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), glSettlement.amount())); + lines.add(VoucherLine.debitGl(glShare, glNameOf(glShare), glCodeOf(glShare), glSettlement.amount())); lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount())); // PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits - // a member's own account -- the value is moving out of Sundry toward an eventual payout - // (e.g. a second, independent manual entry crediting a "DD Payable" account out to the - // actual draft), which is the payment series, not a receipt. + // a member's own account -- the value is moving out of share capital toward an eventual + // payout (e.g. a second, independent manual entry crediting a "DD Payable" account out to + // the actual draft), which is the payment series, not a receipt. final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines); // Every other request type that raises a voucher stores its own voucher_no back on @@ -1002,7 +1045,7 @@ public class ClerkShareWithdrawalRequestApiResource { /** * The voucher for a request that settles nowhere further than leg 1 itself (settlement mode - * 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #routeRedeemIntoSundry} just + * 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #postRedemptionEntry} just * posted, given its own standalone voucher instead of only being visible as "Also posted" under * some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}). * @@ -1020,7 +1063,7 @@ public class ClerkShareWithdrawalRequestApiResource { * dev/test environment can violate — an older, unrelated redemption that happened to land on the * same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total * shown here even though only one pair was ever posted for this approval. This call runs - * immediately after {@link #routeRedeemIntoSundry} in the same approve(), so the highest-id pair + * immediately after {@link #postRedemptionEntry} in the same approve(), so the highest-id pair * is always the one it just inserted. */ private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) { @@ -1079,14 +1122,19 @@ public class ClerkShareWithdrawalRequestApiResource { * Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the * member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry * (Dr whichever GL the settlement payment type's Fund Source maps to / Cr Savings Control) is then - * reversed in full and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used - * to be left to the payment type's own mapping in the hope it would land on the same account - * {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it - * did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually - * met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental. + * reversed in full, and nothing is posted in its place here: the caller redirects the redemption's own + * credit leg straight onto the Savings Control GL returned below, so the ledger carries one entry — Dr Share + * Capital / Cr Savings Control — instead of a pair routed through Sundry and back out again. + * + *

+ * The debit side used to be left to the payment type's own Fund Source mapping in the hope it would land on the + * account the redemption leg had credited, netting to zero; when that mapping pointed anywhere else (as it did the + * day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually met, leaving + * Sundry debited for money that was never credited to it. Naming the destination explicitly, and posting only + * once, removes that class of mismatch entirely. */ private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr, - final String resolutionNumber, final Long settlementPaymentTypeId) { + final String resolutionNumber) { if (shareTransactionId == null) { return null; } @@ -1105,57 +1153,102 @@ public class ClerkShareWithdrawalRequestApiResource { final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount"); - final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId - : jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class, - SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null); - - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "dd MMMM yyyy"); - payload.addProperty("transactionDate", formatForFineract(requestedDateStr)); - payload.addProperty("transactionAmount", settlementAmount); - if (paymentTypeId != null) { - payload.addProperty("paymentTypeId", paymentTypeId); - } - payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber); - - final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId) - .withJson(payload.toString()).build(); - final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Long savingsTransactionId = result.getResourceId(); - - // The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather - // than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands - // on its own regardless of which GL the payment type's Fund Source mapping happened to hit. - // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. - // after a sequence reset), this can only flip rows that are currently live, never - // re-touch an already-reversed unrelated transaction's rows. - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - "S" + savingsTransactionId); - - final Map acct = loadShareAccountGlContext(shareAccountId); - final Long productId = toLong(acct.get("product_id")); - final Long officeId = toLong(acct.get("office_id")); - final String currencyCode = (String) acct.get("currency_code"); - final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); final Long glSavingsControl = savingsControlGlOf(savingsAccountId); if (glSavingsControl == null) { throw apiError(409, "The Savings Control GL is not mapped for this savings account's product."); } - final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10)); + // Office comes from the share account the redemption was posted against, not from a join + // through the savings account's client -- a savings account can be held by a group, where + // that join yields nothing and the insert below would fail on a null office. + final Long officeId = toLong(loadShareAccountGlContext(shareAccountId).get("office_id")); + if (officeId == null) { + throw apiError(409, "The share account has no office on it, so the settlement transaction cannot be booked."); + } + + final Long savingsTransactionId = directCreditSavingsAccount(savingsAccountId, officeId, settlementAmount, requestedDateStr, + resolutionNumber); + + return new Settlement(settlementAmount, savingsTransactionId, glSavingsControl); + } + + /** + * Credits the member's savings account as a book transfer: the transaction row and the balance, + * written directly, with no journal entry of its own. + * + *

+ * The obvious way to do this is Fineract's own deposit command, and that is what this used to + * call. But that command posts its own Dr Cash at Vault / Cr Savings Control pair on the + * assumption that notes came over the counter, which they did not — the money is moving out of + * share capital inside one approval. Reversing that pair afterwards worked, yet left two dead + * rows in the ledger for every redemption and a Cash at Vault leg that had to be explained to + * anyone reading the table. Writing the transaction directly means those rows are never created + * at all, and the redemption's own entry — Dr Share Capital / Cr Savings Control, posted by the + * caller — is the only accounting the event produces. + * + *

+ * This is the same technique the disbursement register already uses for its own book transfers + * ({@code ClerkDisbursementRequestApiResource.directCreditSavingsAccount()}), for the same + * reason, and carries the same trade-off: the native command's interest recalculation, lien and + * overdraft checks and business events are all skipped. That is acceptable here because a + * redemption settlement is a plain credit of a known amount into an active account on the + * approval date, which is exactly the case those extras do not apply to. + * + *

+ * The passbook and statement are unaffected: both read {@code m_savings_account_transaction}, + * which is what this writes. + */ + private Long directCreditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount, + final String requestedDateStr, final String resolutionNumber) { + final Map acct = jdbcTemplate + .queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream() + .findFirst().orElse(null); + if (acct == null) { + throw apiError(409, "The linked savings account " + savingsAccountId + " no longer exists."); + } + final int statusEnum = ((Number) acct.get("status_enum")).intValue(); + if (statusEnum != SAVINGS_STATUS_ACTIVE) { + throw apiError(409, "The linked savings account is not active, so the redemption cannot be settled into it."); + } + final BigDecimal currentBalance = acct.get("account_balance_derived") != null + ? (BigDecimal) acct.get("account_balance_derived") + : BigDecimal.ZERO; + + final java.sql.Date sqlDate = java.sql.Date.valueOf(requestedDateStr.substring(0, 10)); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20); final long userId = context.authenticatedUser().getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHRDMSTL-" + System.nanoTime(); - final String accountNo = (String) acct.get("account_no"); - insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share redemption settled to savings | " + accountNo); - insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share redemption settlement credited to savings | " + accountNo); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + // transaction_type_enum 1 = deposit. + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction " + + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, " + + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, " + + "running_balance_derived, is_lien_transaction, " + + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) " + + "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" }); + ps.setLong(1, savingsAccountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setDate(5, sqlDate); + ps.setString(6, refNo); + ps.setBigDecimal(7, currentBalance.add(amount)); + ps.setLong(8, userId); + ps.setLong(9, userId); + ps.setObject(10, now); + ps.setObject(11, now); + return ps; + }, kh); + final long savingsTransactionId = Objects.requireNonNull(kh.getKey()).longValue(); - return new Settlement(settlementAmount, savingsTransactionId); + jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, " + + "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId); + + LOG.info("Share redemption settled into savings account {} as transaction {} (Resolution No. {})", savingsAccountId, + savingsTransactionId, resolutionNumber); + return savingsTransactionId; } // ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─ @@ -1226,58 +1319,65 @@ public class ClerkShareWithdrawalRequestApiResource { } } - // ── Sundry pass-through helpers ────────────────────────────────────────── + // ── The redemption's own accounting ────────────────────────────────────── /** - * Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry. + * Posts the redemption as one balanced entry against the account the money actually reached. * *

- * Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr - * Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the - * member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement - * posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited - * for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference" - * GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be - * left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry - * the same way every other share-capital movement in this codebase does. + * Share capital is debited and the destination credited, and that is the whole entry: Dr Share + * Equity / Cr the linked savings account's control GL, or the GL the clerk picked, or Share Sundry + * where the clerk chose to settle nowhere further. No "Shares Reference" account appears anywhere + * in it. Fineract's own bridge would have credited that account instead — a placeholder for a + * counterparty it cannot know — so the command that creates the share transaction runs inside + * {@link ShareAccountingSuppression#withOwnShareEntries} and posts nothing of its own. + * + *

+ * A redemption charge, where the product has one, is income and is credited separately: share + * capital carries the full amount plus the charge, the member receives the amount, and the + * difference lands in Income from Fees. With no charge configured the third line is simply absent. + * + *

+ * The transaction_id is the one the native command would have used ({@code "SH" + id}), so the + * entry groups and reconciles exactly as every other share posting in the platform does, and + * anything reading a share transaction's entries by that id keeps working unchanged. */ - private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate, - final long userId) { + private void postRedemptionEntry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate, + final long userId, final Long destinationGlId, final String narration) { + final Map txn = jdbcTemplate + .queryForList("SELECT amount, COALESCE(charge_amount, 0) AS charge_amount FROM m_share_account_transactions WHERE id = ?", + shareTransactionId) + .stream().findFirst().orElse(null); + if (txn == null) { + return; + } + final BigDecimal amount = (BigDecimal) txn.get("amount"); + final BigDecimal charge = (BigDecimal) txn.get("charge_amount"); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + final Map acct = loadShareAccountGlContext(shareAccountId); final Long productId = toLong(acct.get("product_id")); final Long officeId = toLong(acct.get("office_id")); final String currencyCode = (String) acct.get("currency_code"); final String accountNo = (String) acct.get("account_no"); - final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"); - final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); - - final Map reversedLeg = jdbcTemplate - .queryForList( - "SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? " - + " AND type_enum = ? AND reversed = false", - shareTransactionId, glReference, CREDIT) - .stream().findFirst().orElse(null); - if (reversedLeg == null) { - // Nothing to redirect -- the native posting didn't credit Shares Reference at all (an - // unexpected accounting configuration), so there is no leg here to reroute. - return; - } - final BigDecimal amount = (BigDecimal) reversedLeg.get("amount"); - // Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are - // grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr - // Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone, - // instead of the two reading as the one balanced entry they actually are. - final String nativeTxnId = (String) reversedLeg.get("transaction_id"); - - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? " - + " AND type_enum = ? AND reversed = false", - shareTransactionId, glReference, CREDIT); + final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Equity"); + final boolean hasCharge = charge != null && charge.compareTo(BigDecimal.ZERO) > 0; + final String nativeTxnId = SHARE_TXN_ID_PREFIX + shareTransactionId; final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share redemption held in Sundry pending settlement | " + accountNo); + + insertEntry(officeId, glEquity, DEBIT, hasCharge ? amount.add(charge) : amount, nativeTxnId, entryDate, shareAccountId, + shareTransactionId, userId, now, currencyCode, "Share redemption | " + accountNo); + insertEntry(officeId, destinationGlId, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now, + currencyCode, narration + " | " + accountNo); + if (hasCharge) { + final Long glFees = requireShareGl(productId, FA_INCOME_FROM_FEES, "Income from Fees"); + insertEntry(officeId, glFees, CREDIT, charge, nativeTxnId, entryDate, shareAccountId, shareTransactionId, userId, now, + currencyCode, "Share redemption charge | " + accountNo); + } } private Map loadShareAccountGlContext(final Long shareAccountId) { @@ -1349,19 +1449,31 @@ public class ClerkShareWithdrawalRequestApiResource { return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); } + /** + * {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual + * entry normally would: these rows stand in for the ones the platform's own bridge would have + * posted, so they have to be indistinguishable from them to everything that goes looking. + * + *

+ * The voucher builders here match on {@code share_transaction_id}; Fineract's own + * {@code revertShareAccountJournalEntries} matches on {@code transaction_id} and + * {@code entity_type_enum = SHARES}, and would silently skip a row missing that column, leaving + * the accounting standing after the transaction behind it had been reverted. + */ private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, - final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, - final String description) { + final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId, + final LocalDateTime now, final String currencyCode, final String description) { GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " - + " entry_date, type_enum, amount, description, entity_id, " + + " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_id, " + " created_by, last_modified_by, created_date, lastmodified_date, " + " is_running_balance_calculated, office_running_balance, " + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " - + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, - userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate), + java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index 71ca09351..2c1d1e584 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -47,6 +47,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -520,7 +522,7 @@ public class DividendClaimRequestApiResource { final Long ddPayableAccountId = ddPayableAccount(); final java.sql.Date entryDate = java.sql.Date.valueOf(today); - final String transactionId = "DIVDD" + claimId; + final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLAIM); // Stamped with the financial year the dividend was DECLARED for, not the year it is being // claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot // say how much of each year's declaration has actually been claimed. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index d5e81a559..d2fc838bb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -27,6 +27,8 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -225,7 +227,11 @@ public class SavingsPooledInterestPostingService { final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); final String currency = (String) due.get(0).get("currency_code"); - final String poolReference = "SIP-" + productId + "-" + postingDate; + // Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted, + // and the resumed legs have to carry the same reference as the legs already posted, or one + // interest posting ends up split across two transaction ids. + final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate, + productId); // Stage 1: the debt the accrual has been building moves out of the payable and into the // account the payout is made from, so what is still merely owed and what is on its way to diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index bf3465113..ffb0ce4ef 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -562,8 +562,31 @@ public class ShareAccount extends AbstractPersistableCustom { this.totalSharesPending = shares; } + /** + * Finds this account's own instance of a transaction, after a save has replaced the caller's copy. + * + *

+ * Matched on identity where there is one. The callers run this right after {@code saveAndFlush}, + * by which point the transaction has been assigned its id, and matching on that is exact. The + * field comparison below is the fallback for a transaction that has not been persisted yet. + * + *

+ * The fallback cannot stand on its own, because the fields it compares do not identify a + * transaction: two redemptions of the same number of shares, at the same price, on the same day + * are identical in every one of them. When that happened the loop returned the earlier of + * the two and the caller went on to treat the new transaction as that one -- posting the second + * redemption's journal entries under the first one's transaction id, so one share transaction + * appeared to carry two redemptions and its voucher showed double the money that actually moved. + */ public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) { ShareAccountTransaction returnTrans = null; + if (transaction.getId() != null) { + for (ShareAccountTransaction tran : this.shareAccountTransactions) { + if (transaction.getId().equals(tran.getId())) { + return tran; + } + } + } for (ShareAccountTransaction tran : this.shareAccountTransactions) { // A charge/fee transaction carries no share count or unit price at all (both columns are // null), so this account's own transaction list is not uniformly non-null the way a diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index ff3afb640..448f14fc3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -867,7 +867,7 @@ public class ShareAccountDataSerializer { advanceCollectionId); if (rows.isEmpty()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) - .failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found."); + .failWithCodeAndMessage("share.advance.collection.not.found", "A voucher for this payment could not be found."); continue; } final Map collection = rows.get(0); @@ -878,7 +878,7 @@ public class ShareAccountDataSerializer { final Number collectionClientId = (Number) collection.get("client_id"); if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) - .failWithCode("share.advance.collection.client.mismatch", + .failWithCodeAndMessage("share.advance.collection.client.mismatch", "Voucher " + label + " belongs to a different member."); } @@ -887,13 +887,13 @@ public class ShareAccountDataSerializer { final String status = (String) collection.get("status"); if (!"UNAPPLIED".equals(status)) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) - .failWithCode("share.advance.collection.not.unapplied", + .failWithCodeAndMessage("share.advance.collection.not.unapplied", "Voucher " + label + " is no longer available — it reads " + status + "."); } if (isReservedByAnotherTranche(advanceCollectionId)) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) - .failWithCode("share.advance.collection.already.reserved", + .failWithCodeAndMessage("share.advance.collection.already.reserved", "Voucher " + label + " is already reserved against another pending share request."); } @@ -907,8 +907,21 @@ public class ShareAccountDataSerializer { .multiply(BigDecimal.valueOf(sharesRequested)); if (receiptTotal.compareTo(trancheValue) < 0) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal) - .failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal + .failWithCodeAndMessage("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal + ", which does not cover the " + trancheValue + " value of the shares requested."); + } else if (receiptTotal.compareTo(trancheValue) > 0 && account.getSavingsAccount() == null) { + // An over-payment is settled by crediting the difference to the member's linked savings + // account (see AdditionalShareTrancheFundingService.postSharesIssued) -- with none linked, + // approval would have nowhere to put it. Caught here, at submit, rather than left to fail + // once the maker has already gone: short of a member paying the exact figure, this is + // refused up front instead of accepted and then unable to settle. + final BigDecimal excessAmount = receiptTotal.subtract(trancheValue); + baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal) + .failWithCodeAndMessage("share.advance.collection.excess.no.savings", + "The vouchers selected come to " + receiptTotal + ", which is " + excessAmount + " more than the " + + trancheValue + " value of the shares requested. The extra " + excessAmount + + " must be credited to the member's savings account, but none is linked to this share account. " + + "Link one, or collect the exact amount."); } } @@ -1212,7 +1225,12 @@ public class ShareAccountDataSerializer { private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction, final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List dataValidationErrors) { final Long minimumClientShares = account.getShareProduct().getMinimumClientShares(); - if (minimumClientShares == null || remainingShares >= minimumClientShares) { + // The minimum only protects an account that is staying open with a reduced holding -- it has + // nothing to say about one being emptied out entirely. A member redeeming every share they + // hold is exiting, not falling short of a floor; the account closes right behind this + // transaction (see closeShareAccountIfSharesZero), and there is no remaining balance left for + // "at least the minimum" to mean anything about. + if (minimumClientShares == null || remainingShares == 0 || remainingShares >= minimumClientShares) { return; } final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency(); @@ -1317,17 +1335,20 @@ public class ShareAccountDataSerializer { } AppUser approvedUser = this.platformSecurityContext.authenticatedUser(); - // Only synthesize a redeem transaction (and its GL posting) when shares actually remain to - // be redeemed — an account closed after every share was already withdrawn has nothing left - // to force-redeem, and posting a zero-share/zero-value transaction just clutters the GL with - // a meaningless ₹0.00/₹0.00 entry pair. + // A close with shares still outstanding used to force-redeem them right here -- one native + // command, no maker-checker, liquidating the member's holding and crediting cash back to the + // GL Fineract's own bridge names, all in the single step of closing the account. That is + // exactly the governed Share Withdrawal flow's job: a clerk raises the redemption, a manager + // approves it, and it settles to wherever the clerk actually keyed -- the member's savings, a + // chosen GL, or Sundry. Refusing here instead of quietly redeeming is what makes closing an + // account with a balance impossible to do any way other than through that flow; the frontend + // already hid its own "Close" button for the same reason, but the command underneath was still + // reachable directly and this is what actually closes that gap. final Long remainingShares = account.getTotalApprovedShares(); if (remainingShares != null && remainingShares > 0) { - final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate()); - ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, remainingShares, unitPrice); - account.addAdditionalPurchasedShares(transaction); - handleRedeemSharesChargeTransactions(account, transaction); - actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction); + baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname).value(closedDate) + .failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.with.shares.outstanding"); + throw new PlatformApiDataValidationException(dataValidationErrors); } account.close(closedDate, approvedUser); return actualChanges; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index 68b73f81b..fcf1b502a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -18,20 +18,18 @@ */ package org.apache.fineract.portfolio.shareaccounts.service; -import com.google.gson.JsonObject; import java.math.BigDecimal; +import java.sql.PreparedStatement; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; -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.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -41,11 +39,13 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransactio import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Component; /** - * Funds one additional-share tranche — moves the member's money into the Share Sundry holding - * account, where it waits for the tranche to be approved. + * Funds one additional-share tranche — records how the member is paying for it, and turns that + * money into share capital once the tranche is approved. * *

* Why this is separate from the account-level settlement resource. {@code ShareAdvanceCollectionApiResource} @@ -55,35 +55,33 @@ import org.springframework.stereotype.Component; * valued and recorded per transaction, and leaves that account-level state alone. * *

- * Where the money ends up. Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts - * the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by - * {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only - * gets the money into Sundry in the first place. + * What is posted, and when. Nothing is posted when a tranche is raised — a request that is never approved + * never touches anybody's money. {@code settle()} books the whole thing at approval, and the entry is the transfer the + * clerk actually keyed: * *

    - *
  • Cash — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche - * leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for - * money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.
  • - *
  • Savings — not there yet, so this debits the member: + *
  • Savings — the member is debited and share capital issued, in one pair: * *
      *   Dr  Savings Control   (tranche value)
    - *       Cr  Share Sundry      (tranche value)
    + *       Cr  Share Capital     (tranche value)
      * 
    * - * The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal - * command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts - * its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are - * reversed and replaced with the pair above.
  • + * Their balance is checked on submit so a request that cannot be paid for is refused up front, but the money only + * leaves when the shares are issued. The debit is written straight into their account rather than through Fineract's + * withdrawal command, which would post a cash pair for cash that never moves. + *
  • Voucher — the money is already in whichever account the cashier banked the receipt into, so that account + * is debited and share capital credited. Submitting only reserves the receipt against this tranche so it cannot be + * spent twice.
  • *
* *

- * Note that Fineract's stock accounting also posts an APPLIED leg of its own for a new purchase - * (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route - * here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those - * entries when the voucher was issued, and on the savings route the money came from the member's account rather than the - * till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once - * by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once. + * Fineract's stock accounting would post an APPLIED leg of its own when the tranche is raised + * (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now, and an APPROVED + * leg out of the product's mapped Sundry account. Neither matches either route here, so neither is made for a funded + * tranche (see {@code ShareAccountWritePlatformServiceJpaRepositoryImpl}). They used to be posted and reversed a moment + * later, which left dead rows in the ledger on every tranche and a Shares Reference leg that had to be explained to + * anyone reading the account. */ @Component @RequiredArgsConstructor @@ -94,22 +92,21 @@ public class AdditionalShareTrancheFundingService { private static final int DEBIT = 2; /** acc_product_mapping.financial_account_type for share products. */ - private static final int FA_SHARES_REFERENCE = 1; private static final int FA_SHARES_SUSPENSE = 2; + private static final int FA_SHARES_EQUITY = 4; private static final int PRODUCT_TYPE_SHARES = 4; + /** What Fineract's own bridge prefixes a share transaction's journal entries with. */ + private static final String SHARE_TXN_ID_PREFIX = "SH"; + /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ private static final int PRODUCT_TYPE_SAVINGS = 2; private static final int FA_SAVINGS_CONTROL = 2; private static final int SAVINGS_STATUS_ACTIVE = 300; - /** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */ - private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; - private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; - private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; /** * Funds a freshly raised tranche according to the route recorded on it. @@ -125,20 +122,26 @@ public class AdditionalShareTrancheFundingService { if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { reserveAdvanceCollection(account, transaction); } else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) { - debitSavingsIntoSundry(account, transaction); + validateSavingsFunding(account, transaction); } } /** - * Settles a tranche's funding once the tranche has been approved and its value released from - * Share Sundry into share capital. + * Turns an approved tranche's funding into share capital, and closes the funding record behind it. * - * No journal entries of its own — Fineract's APPROVED posting already moved the money. This only - * closes the funding record, so a voucher whose money is now share capital cannot be presented - * again: until this point the receipt was merely reserved, because approval was still refusable. + * Posts the whole entry itself rather than leaving it to the platform, because only this knows + * where the money actually is. Closing the record last means a voucher whose money is now share + * capital cannot be presented again: until this point the receipt was merely reserved, because + * approval was still refusable. */ public void settle(final ShareAccount account, final ShareAccountTransaction transaction) { - if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) { + final String fundingSource = transaction.getFundingSource(); + if (fundingSource == null) { + return; + } + postSharesIssued(account, transaction); + + if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { return; } // Every receipt behind the tranche, not just the lead one: a member's later instalments have @@ -153,21 +156,170 @@ public class AdditionalShareTrancheFundingService { } /** - * Unwinds the funding of a tranche that was rejected, so the member's money does not sit in - * Share Sundry against shares that will never be issued. + * Issues the share capital for an approved tranche: the money leaves wherever it has been sitting + * and becomes equity. * - * Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits - * Shares Reference, the till, which is only correct for the cash route. The routes part company - * here: + *

+ * The credit is always Share Capital. What the debit is depends on where the money actually is, + * which is the whole point of posting this here rather than letting the platform do it: * *

    - *
  • Cash — nothing to do. The money genuinely came in as cash, so returning it to the - * reference account is right, and releasing the receipt's reservation lets the clerk spend the - * voucher on another tranche or refund it at the counter.
  • - *
  • Savings — the reference credit is wrong: no cash is going back over the counter. - * It is reversed and the member's savings are credited instead, which is where the money came - * from on submit.
  • + *
  • Savings-funded - this service moved it into Share Sundry when the tranche was + * submitted, so Sundry is where it is and Sundry is what clears.
  • + *
  • Voucher-funded - it never moved. It is still in whichever account the cashier banked + * the receipt into, read back from that receipt's own posting. The platform would have debited the + * product's mapped Sundry account instead, taking money out of an account that never received it + * and leaving the one that did holding the balance for ever.
  • *
+ * + *

+ * Receipts are drawn down in order, each capped at what it actually holds, so a tranche paid for + * in instalments clears each receipt for its own share. Anything the receipts do not cover falls + * back to Sundry, which keeps the entry balanced rather than silently posting a one-sided one. + * + *

+ * Whatever a receipt holds beyond what the tranche needed — a member overpaying the lead receipt, + * or naming a further instalment that turns out not to be needed at all — is credited to their + * linked savings account, receipt by receipt, rather than left standing: every named receipt is + * marked APPLIED at the end of {@code settle()} regardless of whether the drawdown below actually + * reached it, so any part of one that this method does not move becomes untraceable money the + * moment that happens — still sitting in whatever GL the cashier banked it to, but with no + * UNAPPLIED receipt left pointing back at it. + */ + private void postSharesIssued(final ShareAccount account, final ShareAccountTransaction transaction) { + final BigDecimal amount = transaction.amount(); + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + final Long productId = account.getShareProduct().getId(); + final Long officeId = account.getOfficeId(); + final String currencyCode = account.getCurrency().getCode(); + final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); + final Long glEquity = requireShareGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); + + final AppUser currentUser = this.context.authenticatedUser(); + final long userId = currentUser.getId(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String txnId = SHARE_TXN_ID_PREFIX + transaction.getId(); + final String accountNo = account.getAccountNumber(); + + if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(transaction.getFundingSource())) { + // The member pays for the shares at the moment they are issued, not when the request was + // raised: their balance is checked on submit but nothing is taken, so a tranche that is + // never approved never touches their account. One pair, Dr their savings / Cr share + // capital, which is exactly the transfer the clerk keyed. + final Long savingsAccountId = account.getSavingsAccount().getId(); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + final Long savingsTxnId = debitSavingsAccount(savingsAccountId, officeId, amount, entryDate); + transaction.updateSavingsDebitRequestId(savingsTxnId); + insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, + "Additional shares paid from savings | " + accountNo); + insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, + "Share capital issued for " + accountNo); + return; + } + + BigDecimal remaining = amount; + BigDecimal excess = BigDecimal.ZERO; + if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) { + // Every named receipt is visited, not just enough of them to cover the tranche: one left + // untouched here would still be marked APPLIED at the end of settle(), so this is the only + // chance to account for its money before that happens. + for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) { + final Map coll = this.jdbcTemplate + .queryForList("SELECT amount, gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId) + .stream().findFirst().orElse(null); + if (coll == null) { + continue; + } + final BigDecimal held = (BigDecimal) coll.get("amount"); + final BigDecimal take = held.min(remaining.max(BigDecimal.ZERO)); + final BigDecimal leftover = held.subtract(take); + final Long receiptGl = receiptGlOf((String) coll.get("gl_transaction_id"), glSundry); + if (take.compareTo(BigDecimal.ZERO) > 0) { + insertEntry(officeId, receiptGl, DEBIT, take, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, "Additional shares issued | " + accountNo); + remaining = remaining.subtract(take); + } + if (leftover.compareTo(BigDecimal.ZERO) > 0) { + // This receipt held more than the tranche needed. Its own GL still has to be + // cleared for the leftover -- the receipt is about to read APPLIED in full -- so + // that leftover is drawn down here too, and handed back below as a savings credit + // rather than left to vanish along with the receipt's UNAPPLIED status. + insertEntry(officeId, receiptGl, DEBIT, leftover, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, "Excess on additional shares receipt refunded | " + accountNo); + excess = excess.add(leftover); + } + } + } + if (remaining.compareTo(BigDecimal.ZERO) > 0) { + insertEntry(officeId, glSundry, DEBIT, remaining, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, + "Additional shares issued from Share Sundry | " + accountNo); + } + + insertEntry(officeId, glEquity, CREDIT, amount, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, + "Share capital issued for " + accountNo); + + if (excess.compareTo(BigDecimal.ZERO) > 0) { + // Validated at submit (see ShareAccountDataSerializer.validateAdvanceCollectionsForTranche): + // a tranche cannot be raised with receipts totalling more than its value unless a savings + // account is linked to receive the difference. Checked again here rather than trusted, + // since this also runs for a tranche raised before that guard existed. + if (account.getSavingsAccount() == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.excess.no.savings", + "This tranche's receipts total " + excess + + " more than the shares cost, but no savings account is linked to credit it to."); + } + final Long savingsAccountId = account.getSavingsAccount().getId(); + final Long glSavingsControl = savingsControlGlOf(savingsAccountId); + if (glSavingsControl == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", + "The Savings Control GL is not mapped for this savings account's product."); + } + // Not recorded on transaction.savingsDebitRequestId: that field means the savings-funded + // route's own debit, a different transaction to a different end -- overloading it here for + // a cash-funded tranche's incidental credit would misname what it actually is. + creditSavingsAccount(savingsAccountId, officeId, excess, entryDate); + insertEntry(officeId, glSavingsControl, CREDIT, excess, txnId, entryDate, account.getId(), transaction.getId(), userId, now, + currencyCode, "Excess on additional shares receipts credited to savings | " + accountNo); + } + } + + /** + * The account a receipt's money is actually sitting in, read back from the posting that put it + * there - the credit leg of that receipt's own journal transaction. Falls back to the product's + * Sundry account for a receipt with no posting recorded against it. + */ + private Long receiptGlOf(final String glTransactionId, final Long fallbackGlId) { + if (glTransactionId == null || glTransactionId.isBlank()) { + return fallbackGlId; + } + final List rows = this.jdbcTemplate.queryForList(""" + SELECT account_id FROM acc_gl_journal_entry + WHERE transaction_id = ? AND type_enum = ? AND reversed = false + ORDER BY id DESC LIMIT 1 + """, Long.class, glTransactionId, CREDIT); + return rows.isEmpty() ? fallbackGlId : rows.get(0); + } + + /** + * Unwinds the funding of a rejected tranche. + * + *

+ * Nothing was ever debited on submit — {@link #postSharesIssued} is where the money actually + * moves, and that never ran, because approval is what would have called it — so there is nothing + * to give back. The only route with a submit-time side effect is cash, where the receipt was + * earmarked against this account; releasing that lets the clerk spend the voucher on another + * tranche or refund it at the counter. Savings has none, so it is a no-op here. */ public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) { final String fundingSource = transaction.getFundingSource(); @@ -176,9 +328,9 @@ public class AdditionalShareTrancheFundingService { } if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) { releaseAdvanceCollection(transaction); - } else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) { - refundSavings(account, transaction); } + // A savings-funded tranche has nothing to unwind: the member is only debited when the shares + // are issued, so a rejected one never took their money in the first place. } /** @@ -192,90 +344,129 @@ public class AdditionalShareTrancheFundingService { } /** - * Returns a rejected tranche's money to the savings account it was taken from. - */ - private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) { - if (account.getSavingsAccount() == null) { - return; - } - final BigDecimal amount = transaction.amount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - return; - } - final Long savingsAccountId = account.getSavingsAccount().getId(); - final Long productId = account.getShareProduct().getId(); - final Long officeId = account.getOfficeId(); - final String currencyCode = account.getCurrency().getCode(); - - final Long glSavingsControl = savingsControlGlOf(savingsAccountId); - if (glSavingsControl == null) { - throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", - "The Savings Control GL is not mapped for this savings account's product."); - } - - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final String note = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber(); - - // Fineract's own deposit command, so the refund reaches the member's passbook and statement - // as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in - // over the counter — so the cash debit is replaced by a Sundry debit below. - final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note); - // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. - // after a sequence reset), this can only flip rows that are currently live, never - // re-touch an already-reversed unrelated transaction's rows. - this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - "S" + savingsTxnId); - - // Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the - // till, so that credit is reversed; the Sundry debit it raised alongside is correct and - // stands. - reverseStockReferenceCredit(transaction.getId(), productId, officeId); - - final AppUser currentUser = this.context.authenticatedUser(); - final long userId = currentUser.getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHTRREF-" + transaction.getId(); - - insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, - "Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber()); - } - - /** - * Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this - * tranche, leaving its Sundry debit standing. - */ - private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) { - final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"); - reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT); - } - - /** - * Reverses this tranche's own unreversed entries on one GL account and one side. + * Takes the tranche value out of the member's savings account as a book transfer: the transaction + * row and the balance, written directly, with no journal entry of its own. * - * Matched on {@code share_transaction_id} rather than the {@code SH} transaction_id string, - * because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a - * single GL and side is what keeps each reversal to the posting it is meant to undo. + *

+ * Fineract's withdrawal command would post its own Dr Savings Control / Cr Cash pair, and no cash + * leaves the branch here - the money becomes share capital. That pair used to be posted and then + * reversed a moment later, leaving two dead rows in the ledger on every tranche. The caller books + * the real pair itself; this only moves the member's own balance. + * + *

+ * The passbook and statement are unaffected: both read {@code m_savings_account_transaction}, + * which is what this writes. Interest recalculation, lien and overdraft checks and business events + * are the trade-off, as on the disbursement register's own book transfers. */ - private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) { - this.jdbcTemplate.update(""" - UPDATE acc_gl_journal_entry SET reversed = true - WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false - """, shareTransactionId, glAccountId, officeId, typeEnum); + private Long debitSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount, + final LocalDate entryDate) { + final Map acct = this.jdbcTemplate + .queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream() + .findFirst().orElse(null); + if (acct == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing", + "The linked savings account " + savingsAccountId + " no longer exists."); + } + if (((Number) acct.get("status_enum")).intValue() != SAVINGS_STATUS_ACTIVE) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active", + "The linked savings account is not active."); + } + final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived") + : BigDecimal.ZERO; + if (balance.compareTo(amount) < 0) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient", + "The linked savings account holds " + balance + ", which is less than the " + amount + " these shares cost."); + } + + final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20); + final long userId = this.context.authenticatedUser().getId(); + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + // transaction_type_enum 2 = withdrawal. + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction " + + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, " + + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, " + + "running_balance_derived, is_lien_transaction, " + + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) " + + "VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" }); + ps.setLong(1, savingsAccountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setDate(5, sqlDate); + ps.setString(6, refNo); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setLong(8, userId); + ps.setLong(9, userId); + ps.setObject(10, now); + ps.setObject(11, now); + return ps; + }, kh); + + this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = total_withdrawals_derived + ? WHERE id = ?", amount, amount, savingsAccountId); + return Objects.requireNonNull(kh.getKey()).longValue(); } - private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", txnDate.toString()); - payload.addProperty("transactionAmount", amount); - payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); - payload.addProperty("note", note); + /** + * Credits the member's savings account as a book transfer, with no journal entry of its own — the + * counterpart to {@link #debitSavingsAccount} for the one case that runs the other way: a cash-funded + * tranche whose receipts added up to more than it cost, where the difference has nowhere else to go + * but back to the member. + * + *

+ * Written directly for the same reason as the debit side: Fineract's deposit command would post its + * own Dr Cash / Cr Savings Control pair, and no cash is arriving at the counter here — the money was + * already in Share Sundry (or wherever the receipt's own GL was), and this is only moving the + * member's own balance to reflect it. No balance/status checks beyond existence: crediting an + * account can never leave it short of anything, unlike a debit. + */ + private Long creditSavingsAccount(final Long savingsAccountId, final Long officeId, final BigDecimal amount, + final LocalDate entryDate) { + final Map acct = this.jdbcTemplate + .queryForList("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId).stream() + .findFirst().orElse(null); + if (acct == null) { + throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.missing", + "The linked savings account " + savingsAccountId + " no longer exists."); + } + final BigDecimal balance = acct.get("account_balance_derived") != null ? (BigDecimal) acct.get("account_balance_derived") + : BigDecimal.ZERO; - final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build(); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId(); + final java.sql.Date sqlDate = java.sql.Date.valueOf(entryDate); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20); + final long userId = this.context.authenticatedUser().getId(); + + final KeyHolder kh = new GeneratedKeyHolder(); + this.jdbcTemplate.update(con -> { + // transaction_type_enum 1 = deposit. + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction " + + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, " + + "is_reversed, transaction_date, amount, submitted_on_date, ref_no, " + + "running_balance_derived, is_lien_transaction, " + + "created_by, last_modified_by, created_on_utc, last_modified_on_utc) " + + "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", new String[] { "id" }); + ps.setLong(1, savingsAccountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setDate(5, sqlDate); + ps.setString(6, refNo); + ps.setBigDecimal(7, balance.add(amount)); + ps.setLong(8, userId); + ps.setLong(9, userId); + ps.setObject(10, now); + ps.setObject(11, now); + return ps; + }, kh); + + this.jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, " + + "total_deposits_derived = total_deposits_derived + ? WHERE id = ?", amount, amount, savingsAccountId); + return Objects.requireNonNull(kh.getKey()).longValue(); } /** @@ -293,12 +484,10 @@ public class AdditionalShareTrancheFundingService { "A cash-funded share request must name the voucher the money was received on."); } - // Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new - // purchase. On this route the cashier already posted exactly that pair when the voucher was - // issued, so leaving it would count the member's money twice — Sundry credited 200 against a - // 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed: - // submitting a cash-funded tranche moves no money, it only earmarks the receipt. - reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId()); + // No accounting at all on this route. The cashier already posted the money when the voucher + // was issued, and submitting a cash-funded tranche moves none of it -- it only earmarks that + // receipt for this account. Fineract's own APPLIED posting, which would have booked the same + // money a second time, is not made for a funded tranche (see applyAddtionalShares). for (final Long collectionId : collectionIds) { this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(), @@ -309,7 +498,7 @@ public class AdditionalShareTrancheFundingService { /** * Savings route: debits the member and credits Share Sundry. */ - private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) { + private void validateSavingsFunding(final ShareAccount account, final ShareAccountTransaction transaction) { if (account.getSavingsAccount() == null) { throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings", "This share account has no linked savings account to debit."); @@ -332,90 +521,10 @@ public class AdditionalShareTrancheFundingService { throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient", "Savings account " + savings.get("account_no") + " does not hold enough to cover this share request."); } - - final Long productId = account.getShareProduct().getId(); - final Long officeId = account.getOfficeId(); - final String currencyCode = account.getCurrency().getCode(); - final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); - final Long glSavingsControl = savingsControlGlOf(savingsAccountId); - if (glSavingsControl == null) { + if (savingsControlGlOf(savingsAccountId) == null) { throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped", "The Savings Control GL is not mapped for this savings account's product."); } - - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final String note = "Additional shares funded from savings | share account " + account.getAccountNumber(); - - // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL - // pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch. - final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note); - // Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g. - // after a sequence reset), this can only flip rows that are currently live, never - // re-touch an already-reversed unrelated transaction's rows. - this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - "S" + savingsTxnId); - - // Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry, - // assuming cash over the counter. BOTH legs are reversed, not just the reference debit: - // leaving its Sundry credit standing and then posting the savings pair below would credit - // Sundry twice — 200 held against a 100 debit — and the surplus would never clear. - reverseStockAppliedPair(transaction.getId(), productId, officeId); - - final AppUser currentUser = this.context.authenticatedUser(); - final long userId = currentUser.getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHTRSAV-" + transaction.getId(); - - insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, - "Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber()); - insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode, - "Additional shares held in Share Sundry pending approval | " + account.getAccountNumber()); - - transaction.updateSavingsDebitRequestId(savingsTxnId); - } - - /** - * Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this - * tranche, leaving its Sundry credit standing. - * - * Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so - * the Sundry-to-Share-Capital pair raised later at approval is never caught by it. - */ - private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) { - reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT); - reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"), - CREDIT); - } - - private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, - final String note) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", txnDate.toString()); - payload.addProperty("transactionAmount", amount); - payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); - payload.addProperty("note", note); - - final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) - .build(); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId(); - } - - private Long resolveSettlementPaymentTypeId() { - final List> rows = this.jdbcTemplate - .queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE); - if (!rows.isEmpty()) { - return toLong(rows.get(0).get("id")); - } - final List> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1"); - if (fallback.isEmpty()) { - throw new PlatformDataIntegrityException("error.msg.share.tranche.no.payment.type", - "No payment types are configured. Add one before funding shares from savings."); - } - return toLong(fallback.get(0).get("id")); } private Map loadSavingsAccount(final Long savingsAccountId) { @@ -450,19 +559,28 @@ public class AdditionalShareTrancheFundingService { } } + /** + * {@code share_transaction_id} and {@code entity_type_enum} are written, not left null as a manual + * entry normally would: these rows stand in for the ones the platform would have posted under the + * same {@code SH} transaction id, so they have to be indistinguishable from them to everything + * that goes looking. Fineract's own {@code revertShareAccountJournalEntries} matches on the + * transaction id and {@code entity_type_enum = SHARES}, and would silently skip a row + * missing that column, leaving the accounting standing after the tranche behind it was reverted. + */ private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, - final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, - final String description) { + final LocalDate entryDate, final Long shareAccountId, final Long shareTransactionId, final long userId, + final LocalDateTime now, final String currencyCode, final String description) { GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId); this.jdbcTemplate.update( "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " - + " entry_date, type_enum, amount, description, entity_id, " + + " entry_date, type_enum, amount, description, entity_id, entity_type_enum, share_transaction_id, " + " created_by, last_modified_by, created_date, lastmodified_date, " + " is_running_balance_calculated, office_running_balance, " + " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) " - + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", + + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, - userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); + PRODUCT_TYPE_SHARES, shareTransactionId, userId, userId, now, now, java.sql.Date.valueOf(entryDate), + java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java index e2ff9bf38..d0fcd2409 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountSchedularServiceImpl.java @@ -24,6 +24,8 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; @@ -134,7 +136,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe final AppUser currentUser = this.context.authenticatedUser(); final LocalDate entryDate = DateUtils.getBusinessLocalDate(); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate); - final String transactionId = "DIVFORFEIT" + lockId; + final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT); // Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in — // a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each // year's declaration went unclaimed and came back to profit. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 37410ae78..b6d4c3b73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -178,13 +178,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA if (!changes.isEmpty()) { this.shareAccountRepository.save(account); } - // since we are reverting all journal entries we need to add journal - // entries for application request + // Modifying a pending application replaces its purchase transaction(s) with new ones of + // the same shape, and only ever runs before the account has been approved -- at which + // point nothing has been posted for it in the first place (see createShareAccount: no + // cash has been collected yet, so there is nothing to post until + // ShareAdvanceCollectionApiResource settles the real receipt, which requires the account + // to already be Active). revertShareAccountJournalEntries is still called as a safety net + // for any legacy account that predates that change and does carry a native posting for + // its original transaction; it is a no-op where there is nothing to find. What it must + // NOT do is post a fresh entry for the replacement transaction, which this used to do + // unconditionally -- booking Dr Shares Reference (i.e. "cash received") for an + // application nobody has paid for yet. if (changes.containsKey("reversalIds")) { ArrayList reversalIds = (ArrayList) changes.get("reversalIds"); this.journalEntryWritePlatformService.revertShareAccountJournalEntries(reversalIds, transactionDate); - journalEntryWritePlatformService.createJournalEntriesForShares( - populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions())); changes.remove("reversalIds"); } return new CommandProcessingResultBuilder() // @@ -217,9 +224,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA changes.put(ShareAccountApiConstants.additionalshares_paramname, transaction.getId()); Set transactions = new HashSet<>(); transactions.add(transaction); - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - // After the stock APPLIED posting, not before: the savings route reverses that - // posting's Shares Reference debit, which has to exist to be reversed. + // The stock APPLIED posting assumes cash came over the counter for these shares, + // and books Dr Shares Reference / Cr Share Sundry. A tranche funded through one of + // this fork's own routes did not arrive that way: the cash route's money was + // already taken at the till against a voucher, and the savings route debits the + // member's own account. Both used to let this post and then reverse it a moment + // later, leaving two dead rows behind on every tranche. Not posting it is the same + // outcome without the wreckage -- fund() below books what actually happened. + if (transaction.getFundingSource() == null) { + this.journalEntryWritePlatformService + .createJournalEntriesForShares(populateJournalEntries(account, transactions)); + } this.trancheFundingService.fund(account, transaction); } } @@ -297,15 +312,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA this.noteRepository.save(note); } } - Set transactions = account.getShareAccountTransactions(); - Set journalTransactions = new HashSet<>(); - for (ShareAccountTransaction transaction : transactions) { - if (transaction.isActive() && !transaction.isChargeTransaction()) { - journalTransactions.add(transaction); - } - } - - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions)); + // Rejecting an application only ever happens before the account has been approved, at + // which point nothing has been posted for it -- createShareAccount posts nothing, and the + // real receipt is not booked until ShareAdvanceCollectionApiResource settles it, which + // requires the account to already be Active. Posting a REJECTED entry here used to credit + // Shares Reference (i.e. "cash returned") for cash that was never collected in the first + // place, because this application never reached the stage where any was. return new CommandProcessingResultBuilder() // .withCommandId(jsonCommand.commandId()) // .withEntityId(accountId) // @@ -412,9 +424,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA transactions.add(transaction); totalSubscribedShares += transaction.getTotalShares(); } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - // The APPROVED posting above has released the money from Share Sundry into - // share capital, so the funding behind it can now be closed off. + // The stock APPROVED posting releases the money into share capital out of the + // product's mapped Share Sundry account, whatever the money actually did. For a + // tranche funded through this fork's own routes that account is not always where + // it is: a voucher-funded one is sitting wherever the cashier banked the receipt. + // settle() books those itself, against the account the money is really in. + final Set stockPosted = new HashSet<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.getFundingSource() == null) { + stockPosted.add(transaction); + } + } + if (!stockPosted.isEmpty()) { + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted)); + } for (ShareAccountTransaction transaction : transactions) { this.trancheFundingService.settle(account, transaction); } @@ -451,9 +474,20 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA ShareAccountTransaction transaction = account.retrievePurchasedShares(id); transactions.add(transaction); } - this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions)); - // After the stock REJECTED posting, whose Shares Reference credit the savings - // route reverses in favour of refunding the member. + // The stock REJECTED posting unwinds the stock APPLIED one. A tranche funded + // through this fork's own routes never had that APPLIED posting made (see + // applyAddtionalShares), so posting its reversal here would book the undoing of + // something that was never booked. unwind() below returns the money the way it + // actually came in. + final Set stockPosted = new HashSet<>(); + for (ShareAccountTransaction transaction : transactions) { + if (transaction.getFundingSource() == null) { + stockPosted.add(transaction); + } + } + if (!stockPosted.isEmpty()) { + this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, stockPosted)); + } for (ShareAccountTransaction transaction : transactions) { this.trancheFundingService.unwind(account, transaction); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index 7a9ac3d05..f35f10d21 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -31,6 +31,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; +import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService; import org.apache.fineract.commands.domain.CommandProcessingResultType; import org.apache.fineract.commands.domain.CommandSource; @@ -304,7 +306,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP // back-dating it to 31 March files it months before it happened, where nobody reviewing the // approval would find it. The financial year it belongs to is carried in the description. postJournalEntryPair(distributableAccountId, settledAccountId, remainder, currencyCodeOfProduct(period.get("product_id")), - "DIVCLOSE" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), + LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_CLOSED), + java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), "FY " + financialYear + " undistributed profit closed off after dividend approval — declaration #" + declaration.getId()); } @@ -406,7 +409,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate(); final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); - final String transactionId = "DIVROUND" + dividendPayOutId; + final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_ROUNDING); // Approval credited the whole declared amount to Dividend Payable; this takes back the slice // that will never be paid to a member, because no member's share divided into it evenly. @@ -555,7 +558,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final AppUser currentUser = this.context.authenticatedUser(); final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()); - final String transactionId = "DIVDECL" + dividendPayOutId; + final String transactionId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_DECLARED); // The financial year is stamped into every dividend journal entry so the ledger itself can // answer "how much of FY 2026-27's dividend is still unclaimed" — the GL accounts are shared // across years, and without the year on the line the balances only ever give a running total. @@ -602,7 +605,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP + " (Reserve released to members)"; postJournalEntryPair(reserveAccountId, payableDividendsAccountId, amount, product.getCurrency().getCode(), - "DIVAPPR" + declaration.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description); + LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_APPROVED), + java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), description); } /** @@ -712,7 +716,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate()); postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"), - "DIVFORF" + lockId, entryDate, + LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.DIVIDEND_FORFEIT), entryDate, "FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS + " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index a52042032..75aa18bf7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -940,88 +940,20 @@ public class VoucherVerificationApiResource { * The other side of a split transaction — GL entries a native platform command posted on its own, * outside this voucher, for the same underlying event. * - * Most vouchers already ARE the whole transaction: {@code raiseLoanVoucher()}, - * {@code raiseClosureVoucher()} and the rest each build every line themselves from the amount they - * are posting, so there is nothing else to find. Share redemption is the one place today where - * that is not true: approval runs the native {@code redeemshares} command, which posts its own - * Dr Share Capital / Cr Share Sundry pair directly (no voucher raised for it, since it is not this - * class that posts it). Only the later savings-settlement leg (Dr Sundry / Cr Savings Control) - * becomes a voucher. Showing just that half leaves a reader unable to see where the money - * actually came from. + * Nothing splits that way today, so this returns empty for every voucher. Share redemption used to: + * approval posted Dr Share Capital / Cr Share Sundry through the native {@code redeemshares} + * command, and only the settlement that followed (Dr Sundry / Cr Savings Control) became a + * voucher, so a reader saw half an entry and no sign of where the money came from. A redemption + * now books as the single entry the clerk actually keyed — Dr Share Capital straight to its + * destination — and the voucher carries both of its sides, so there is no longer a second half to + * show. Returning those same rows here would print this voucher's own lines a second time under a + * banner saying they are not part of it. * - * Extending this for a future split transaction is one more {@code if} branch keyed on - * referenceType, in the same shape as the share-withdrawal one below — not a new endpoint or a new - * field on the response. + * Kept rather than deleted because it is the seam a genuinely split transaction would hook into: + * one {@code if} branch keyed on referenceType, not a new endpoint or a new field on the response. */ private List> relatedGlEntries(final String referenceType, final Object referenceId) { - if (!VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST.equals(referenceType) || referenceId == null) { - return List.of(); - } - final List> requestRows = jdbcTemplate.queryForList( - "SELECT share_transaction_id, settlement_mode, actioned_at FROM m_clerk_share_withdrawal_request " - + "WHERE id = ? AND share_transaction_id IS NOT NULL", - referenceId); - if (requestRows.isEmpty()) { - return List.of(); - } - // A settlement_mode 'NONE' request's own voucher (raiseSundryOnlyVoucher()) already IS leg 1 - // in full -- Dr Share Capital, Cr Share Sundry -- so showing it again here as "related" would - // duplicate this voucher's own lines under a banner claiming they are not part of it. - if ("NONE".equals(requestRows.get(0).get("settlement_mode"))) { - return List.of(); - } - final Long shareTxnId = Long.parseLong(String.valueOf(requestRows.get(0).get("share_transaction_id"))); - final java.sql.Timestamp actionedAt = (java.sql.Timestamp) requestRows.get(0).get("actioned_at"); - // Matched by transaction_id, not share_transaction_id: routeRedeemIntoSundry() reroutes the - // native posting's Cr Shares Reference leg by reversing it and inserting a fresh Cr Sundry row - // that carries the same transaction_id but no share_transaction_id of its own (only the native - // rows get that column populated) -- filtering on share_transaction_id found the Dr Link Share - // leg but silently dropped its Cr Sundry counterpart, showing only half of a balanced entry. - // transaction_id is what groups a balanced entry in this codebase (see routeRedeemIntoSundry's - // own comment), and the savings-settlement leg raiseShareWithdrawalVoucher() itself posts - // carries its own distinct transaction_id, so this still cleanly excludes it — that leg is - // already covered by this voucher's own lines. - // - // "SH" + shareTransactionId is only unique so long as m_share_account_transactions.id never - // repeats, which a reset/reseeded id sequence in a dev/test environment can violate -- two - // unrelated redemptions landing on the same id would otherwise share one transaction_id, and - // this query would return both pairs instead of the one this specific request actually - // posted. Disambiguated by picking whichever candidate transaction_id's own entry_date sits - // closest to actioned_at (when this request was approved), rather than assuming there is - // only ever one. - final List> nativeTxnCandidates = actionedAt == null - ? jdbcTemplate.queryForList("SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", - shareTxnId) - : jdbcTemplate.queryForList(""" - SELECT transaction_id FROM acc_gl_journal_entry - WHERE share_transaction_id = ? - GROUP BY transaction_id - ORDER BY MIN(ABS(EXTRACT(EPOCH FROM (created_date - ?::timestamp)))) ASC - LIMIT 1 - """, shareTxnId, actionedAt); - if (nativeTxnCandidates.isEmpty()) { - return List.of(); - } - final String nativeTxnId = String.valueOf(nativeTxnCandidates.get(0).get("transaction_id")); - final List> rows = jdbcTemplate.queryForList(""" - SELECT je.entry_date, je.type_enum, je.amount, je.description, ga.name AS gl_name, ga.gl_code - FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id - WHERE je.transaction_id = ? AND je.reversed = false - ORDER BY je.id - """, nativeTxnId); - - final List> out = new java.util.ArrayList<>(); - for (final Map r : rows) { - final Map m = new LinkedHashMap<>(); - m.put("stage", "Posted at manager approval"); - m.put("entryType", ((Number) r.get("type_enum")).intValue() == 2 ? "DR" : "CR"); - m.put("partyName", r.get("gl_name")); - m.put("accountNo", r.get("gl_code")); - m.put("amount", r.get("amount")); - m.put("description", r.get("description")); - out.add(m); - } - return out; + return List.of(); } // ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ──────────────────────── diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 2f0be1dc1..0e6164682 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -108,4 +108,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml new file mode 100644 index 000000000..e89250971 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0563_ledger_txn_sequence.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 4c0512f154983ae9c63066145aed8d761c291b4e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 17 Sep 2026 17:39:59 +0530 Subject: [PATCH 49/55] feat(shares, cashier): tag GL counter entries by purpose, close DD/Share voucher cross-use A cashier Receipt or clerk Collection into a GL account never named who or what it was for, so a manager applying a share request could adopt a voucher a cashier had actually collected for a Demand Draft, and vice versa. Adds a GLEntryType code (General/Share Collection/DD Issue, extensible via Manage Codes) that the counter sets at save time; Share Advance Collection and Sponsor DD issuance both check it before adopting a voucher, and also cross-check each other's own consumption tables directly so the guard holds even for untagged legacy rows. Also: - Share-Collection-tagged vouchers auto-surface on Apply Share / Additional Share screens instead of requiring a manual GL-account search, but only when a matching client_id already exists on the row or the request is scoped to a specific member's screen -- a clientless GL receipt is never auto-attributed to whoever's screen happened to be open. - gl_transaction_id column on m_clerk_disbursement_request captures the real GL posting a Collection approval makes, previously generated and discarded. - New GET .../gl-entries endpoint exposes a receipt's actual Dr/Cr legs for detail popups. - Additional-share tranches restricted to a single funding voucher for now. Co-Authored-By: Claude Sonnet 5 --- .../api/CashierDepositRequestApiResource.java | 30 ++- .../ShareAdvanceCollectionApiResource.java | 251 ++++++++++++++++-- .../ClerkDisbursementRequestApiResource.java | 42 ++- .../api/ShareAdditionRequestsApiResource.java | 2 +- .../ShareAccountDataSerializer.java | 9 + ...DIssueRequestWritePlatformServiceImpl.java | 17 +- .../SponsorDDReadPlatformServiceImpl.java | 6 + .../tenant/final-changelog-tenant.xml | 3 + .../0564_add_entry_type_to_gl_requests.xml | 66 +++++ .../0565_convert_entry_type_to_code_value.xml | 124 +++++++++ ...ction_id_to_clerk_disbursement_request.xml | 37 +++ 11 files changed, 553 insertions(+), 34 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index c1588aa0e..7378801ec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource { final String bankName = fromJsonHelper.extractStringNamed("bankName", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // What the counter says this GL credit is actually for — a code value under the + // "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row + // is one of the flows below picks up its own money later (Share Advance Collection, Sponsor + // DD), in which case that adoption checks this tag so it can never take a voucher meant for + // the other one. See 0565_convert_entry_type_to_code_value.xml. + final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root)); // DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests. // Persisted so the Sponsor DD issuance screen can read the breakdown back instead of // retyping face value/charges once this request is approved and selected as cash evidence. @@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource { client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource { "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), denominationData, activityLog, Timestamp.from(Instant.now()), - ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId ); // Globally unique voucher number from the central m_voucher register @@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource { + " txn_date, status, cashier_id, cashier_name, cashier_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," + " denomination_data, activity_log, submitted_at, actioned_at," - + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount" + + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id" + " FROM m_cashier_deposit_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource { payment_type_name, description, txn_date, status, cashier_name, manager_name, manager_username, rejection_reason, txn_id, submitted_at, actioned_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id FROM m_cashier_deposit_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -669,7 +675,7 @@ public class CashierDepositRequestApiResource { txn_date, status, cashier_id, cashier_name, cashier_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id FROM m_cashier_deposit_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -1032,6 +1038,18 @@ public class CashierDepositRequestApiResource { } } + /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code, + * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */ + private Long resolveEntryTypeCvId(final Long provided) { + if (provided != null) { + return provided; + } + return jdbcTemplate.queryForObject( + "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id" + + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'", + Long.class); + } + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { JsonArray log = new JsonArray(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index c0c72ff3b..23004022d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -188,11 +188,25 @@ public class ShareAdvanceCollectionApiResource { // GET — Unapplied receipts, optionally for one client // ══════════════════════════════════════════════════════════════════════ @GET + @Transactional public Response listUnapplied(@QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); + // A voucher explicitly tagged Share Collection at save time no longer waits for the counter + // to search it up by hand -- it's pulled onto this list itself, the first time anyone asks + // for it. Anything not tagged this way (an older row, or one deliberately left General) still + // needs the by-voucher search below, same as before. + autoAdoptShareCollectionTagged(clientId); + final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," - + " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note " + + " cl.display_name AS client_name, " + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + // The GL account this specific receipt's money actually sits in -- its own posting's + // credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL + // Account picker above can be kept in sync with whichever receipt gets ticked. + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code " + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "); if (clientId != null) { sql.append(" AND c.client_id = ").append(clientId); @@ -233,7 +247,8 @@ public class ShareAdvanceCollectionApiResource { // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. final List> rows = jdbcTemplate .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id " + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + + " c.applied_share_account_id " + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); @@ -273,19 +288,42 @@ public class ShareAdvanceCollectionApiResource { // GET — GL accounts selectable for a by-voucher lookup // ══════════════════════════════════════════════════════════════════════ /** - * Every active, postable GL account, for the GL account picker on the Apply Collection screen. + * The GL account picker on the Apply Collection screen — narrowed to accounts that actually have + * an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now, + * so the counter is choosing from real, live money rather than the entire chart of accounts + * (which is every postable GL account in the bank, share-related or not, well over a hundred of + * them, Cash at Vault included, with nothing to say which one has anything to find). * - * Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to - * point at today — that mapping is exactly what this lookup no longer trusts, since it can go - * missing or wrong without anyone noticing until a voucher search fails at the counter. The person - * applying a receipt picks the account the deposit actually went to, every time. + * Still never resolved from a share product's own Share Suspense mapping -- that mapping is + * exactly what this lookup no longer trusts, since it can go missing or wrong without anyone + * noticing until a voucher search fails at the counter. This filters by what genuinely sits in + * each account today, not by what any product's configuration claims should. Falls back to the + * unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a + * dead end -- a voucher search should not become unreachable just because this narrowing has + * nothing to narrow to yet. */ @GET @Path("/glaccounts") public Response glAccounts() { this.context.authenticatedUser(); - final List> rows = jdbcTemplate.queryForList( - "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); + List> rows = jdbcTemplate.queryForList( + "SELECT DISTINCT ga.id, ga.gl_code, ga.name" + + " FROM acc_gl_account ga" + + " WHERE ga.disabled = false AND ga.account_usage = 1" + + " AND (" + + " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id" + + " AND d.status = 'APPROVED' AND d.share_account_id IS NULL" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)" + + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))" + + " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id" + + " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))" + + " )" + + " ORDER BY ga.gl_code"); + if (rows.isEmpty()) { + rows = jdbcTemplate.queryForList( + "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); + } final Map out = new LinkedHashMap<>(); out.put("glAccounts", rows); return Response.ok(gson.toJson(out)).build(); @@ -318,6 +356,47 @@ public class ShareAdvanceCollectionApiResource { return Response.ok(gson.toJson(out)).build(); } + // ══════════════════════════════════════════════════════════════════════ + // GET — The actual Dr/Cr GL legs a receipt posted, for the details popup + // ══════════════════════════════════════════════════════════════════════ + /** + * What this receipt actually did to the ledger — the real Dr/Cr legs under its own + * {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box + * (a different, unrelated piece of screen state that a details popup must never be shown + * against, since it can name a completely different account than the one this receipt sits in). + */ + @GET + @Path("/{collectionId}/gl-entries") + public Response glEntries(@PathParam("collectionId") final Long collectionId) { + this.context.authenticatedUser(); + final List> collRows = jdbcTemplate + .queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId); + if (collRows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found."); + } + final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id"); + final List> legs = glTransactionId == null ? List.of() + : jdbcTemplate.queryForList( + "SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount" + + " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id", + glTransactionId); + + final Map out = new LinkedHashMap<>(); + out.put("transactionId", glTransactionId); + final List> entries = new java.util.ArrayList<>(); + for (final Map leg : legs) { + final Map e = new LinkedHashMap<>(); + e.put("glCode", leg.get("gl_code")); + e.put("accountName", leg.get("account_name")); + e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr"); + e.put("amount", leg.get("amount")); + entries.add(e); + } + out.put("entries", entries); + return Response.ok(gson.toJson(out)).build(); + } + // ══════════════════════════════════════════════════════════════════════ // POST — Apply a receipt to an activated share account // ══════════════════════════════════════════════════════════════════════ @@ -609,6 +688,108 @@ public class ShareAdvanceCollectionApiResource { * missing product mapping can never make a genuine voucher unreachable. * @return the newly created collection row, or null when no approved deposit matches. */ + /** + * Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share + * Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional + * Share screen on its own -- the point of tagging a voucher this way at save time in the first + * place. + * + * A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own -- + * buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of + * these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER + * auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's + * screen happened to be open when this ran" is a coincidence of timing, not evidence of whose + * money it is, and would silently hand one member's cash to a completely unrelated member simply + * because their Apply Share screen loaded first. A clientless receipt can only ever be claimed + * through the manual by-voucher search, where a person at the counter is actively vouching for it + * against a member they can see, by quoting the specific voucher number the member is holding -- + * a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id + * (a Clerk Collection, whose client comes from the savings account it actually debited -- real + * evidence, not a guess) is still only adopted against a matching client_id, never a different + * member than the one asking. + */ + private void autoAdoptShareCollectionTagged(final Long clientIdFilter) { + final List> deposits = jdbcTemplate.queryForList( + "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id," + + " d.cashier_id, d.cashier_name, d.description" + + " FROM m_cashier_deposit_request d" + + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL" + + " AND d.client_id IS NOT NULL" + + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"); + for (final Map dep : deposits) { + final Long resolvedClientId = toLong(dep.get("client_id")); + // Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match + // who is asking -- never adopted for a different member than the one whose screen it is. + if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) { + continue; + } + final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date + ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String receiptNo = nextReceiptNo(); + // What the cashier actually typed at save time, when they typed anything -- that is what + // the Apply Share screen's Description column exists to show. Falls back to a plain + // label only for a row nobody described, same as the manual by-voucher adoption below. + final String depDescription = (String) dep.get("description"); + final String note = depDescription != null && !depDescription.isBlank() ? depDescription + : "Share collection - Voucher " + dep.get("voucher_no"); + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")), + java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"), + toLong(dep.get("cashier_id")), dep.get("cashier_name"), note); + } + + final List> collections = jdbcTemplate.queryForList( + "SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name," + + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id" + + " FROM m_clerk_disbursement_request d" + + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"); + for (final Map req : collections) { + final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); + if (savingsAccountId == null) { + continue; + } + final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) { + continue; + } + final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId); + final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR"; + final Object actionedAt = req.get("actioned_at"); + final Object submittedAt = req.get("submitted_at"); + final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp + ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String voucherNo = (String) req.get("voucher_no"); + final String receiptNo = nextReceiptNo(); + final String reqDescription = (String) req.get("description"); + final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription + : "Share collection - Voucher " + voucherNo; + // The real acc_gl_journal_entry.transaction_id this approval posted (see + // 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the + // voucher number only for a row approved before that column existed, since something has + // to go in a NOT NULL-shaped identity column and the voucher number at least resolves + // back to this same row. + final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo; + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, voucherNo, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, + glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note); + } + } + private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) { if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) { throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required", @@ -618,8 +799,10 @@ public class ShareAdvanceCollectionApiResource { final List> deposits = jdbcTemplate .queryForList( "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," - + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id," + + " cv.code_value AS entry_type" + " FROM m_cashier_deposit_request d" + + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", voucherNo, shareSundryAccountNo); @@ -632,6 +815,23 @@ public class ShareAdvanceCollectionApiResource { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", "Voucher " + voucherNo + " has already been applied to a share account."); } + // The counter tags what a GL receipt was actually collected for at save time, from the + // GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never + // pick up money that was really meant for something else (a Demand Draft, say) just because + // it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows + // from before this tag existed) are still adoptable -- only an explicit, different purpose + // blocks it. + final String depositEntryType = (String) dep.get("entry_type"); + if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", + "Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. " + + "Choose a different voucher, or ask the cashier to correct the entry type."); + } + if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?", + toLong(dep.get("id"))).isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " has already been used to issue a Demand Draft."); + } // Prefer the member recorded on the deposit; fall back to the account being settled. final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint; @@ -657,7 +857,7 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } @@ -679,16 +879,27 @@ public class ShareAdvanceCollectionApiResource { private Map adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) { final String glAccountNo = glAccountNoOverride.trim(); final List> requests = jdbcTemplate.queryForList( - "SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at" - + " FROM m_clerk_disbursement_request" - + " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'" - + " ORDER BY id DESC", + "SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name," + + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type" + + " FROM m_clerk_disbursement_request d" + + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " ORDER BY d.id DESC", voucherNo, glAccountNo); if (requests.isEmpty()) { return null; } final Map req = requests.get(0); + // Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL + // account the counter picked by hand, so it carries the identical ambiguity. + final String reqEntryType = (String) req.get("entry_type"); + if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", + "Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. " + + "Choose a different voucher, or ask the clerk to correct the entry type."); + } + final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); if (savingsAccountId == null) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client", @@ -706,16 +917,20 @@ public class ShareAdvanceCollectionApiResource { : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String receiptNo = nextReceiptNo(); + final String reqDescription = (String) req.get("description"); + final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription + : "Adopted from clerk disbursement voucher " + voucherNo; + final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo; jdbcTemplate.update( "INSERT INTO m_share_advance_collection " + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", - receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo, - toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo); + receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId, + toLong(req.get("clerk_id")), req.get("clerk_name"), note); return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 4da3c64d9..a250b0e45 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -116,6 +116,12 @@ public class ClerkDisbursementRequestApiResource { || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) { requestType = "DISBURSEMENT"; } + // What this GL leg is actually for — a code value under "GLEntryType" (Admin > System > + // Manage Codes). A COLLECTION credits a GL account the counter picks by hand, the same + // ambiguity a cashier Receipt has (see CashierDepositRequestApiResource and + // 0565_convert_entry_type_to_code_value.xml). ShareAdvanceCollectionApiResource checks this + // before adopting one of these rows as a share collection. + final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root)); if (creditEntriesJson == null || creditEntriesJson.isBlank()) { throw apiError(400, "At least one entry is required"); @@ -208,8 +214,8 @@ public class ClerkDisbursementRequestApiResource { INSERT INTO m_clerk_disbursement_request (gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code, credit_entries, payment_type_id, payment_type_name, description, - status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type, entry_type_cv_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -218,7 +224,7 @@ public class ClerkDisbursementRequestApiResource { correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - activityLog, Timestamp.from(Instant.now()), requestType + activityLog, Timestamp.from(Instant.now()), requestType, entryTypeCvId ); // A collection pulls money into the GL, so it reads as a credit; a disbursement moves @@ -306,7 +312,7 @@ public class ClerkDisbursementRequestApiResource { + " credit_entries, payment_type_name, description," + " clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username," - + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at" + + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id" + " FROM m_clerk_disbursement_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -373,7 +379,7 @@ public class ClerkDisbursementRequestApiResource { final List> rows = jdbcTemplate.queryForList( "SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount," + " credit_entries, payment_type_name, description, clerk_name," - + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at" + + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id" + " FROM m_clerk_disbursement_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -497,6 +503,11 @@ public class ClerkDisbursementRequestApiResource { final List withdrawalSavingsTxnRefs = new ArrayList<>(); BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO; Long collectionTargetGlId = null; + // The real acc_gl_journal_entry.transaction_id this approval posts -- captured here because + // nothing else on the row remembers it otherwise (txn_refs only holds the account-side + // transaction ids). Whoever needs the actual GL posting afterwards (Share Advance Collection + // adopting a tagged voucher, a GL detail view) reads it back from gl_transaction_id. + String approvedGlTxnId = null; // Savings-control GL data per account for the direct combined journal entry. // collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode). @@ -562,6 +573,7 @@ public class ClerkDisbursementRequestApiResource { linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2); linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1); } + approvedGlTxnId = btGlTxnId; // Audit log in m_bank_transfer_log final String btTxnRefsStr = String.join(",", txnRefs); @@ -707,6 +719,7 @@ public class ClerkDisbursementRequestApiResource { if (disbGlTxnId != null) { linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1); } + approvedGlTxnId = disbGlTxnId; } // ── Collection GL entry ─────────────────────────────────────────────── @@ -729,6 +742,7 @@ public class ClerkDisbursementRequestApiResource { if (collGlTxnId != null) { linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); } + approvedGlTxnId = collGlTxnId; } else { final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); if (cashVaultGlId != null) { @@ -780,11 +794,11 @@ public class ClerkDisbursementRequestApiResource { jdbcTemplate.update(""" UPDATE m_clerk_disbursement_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?, - txn_refs = ?, activity_log = ?, actioned_at = ? + txn_refs = ?, gl_transaction_id = ?, activity_log = ?, actioned_at = ? WHERE id = ? """, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id); + txnRefsStr, approvedGlTxnId, updatedLog, Timestamp.from(Instant.now()), id); final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); @@ -1262,7 +1276,7 @@ public class ClerkDisbursementRequestApiResource { office_id, currency_code, credit_entries, payment_type_id, payment_type_name, description, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, - txn_refs, error_message, activity_log, submitted_at, actioned_at + txn_refs, error_message, activity_log, submitted_at, actioned_at, entry_type_cv_id FROM m_clerk_disbursement_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -1321,6 +1335,18 @@ public class ClerkDisbursementRequestApiResource { try { return new BigDecimal(raw); } catch (NumberFormatException e) { return null; } } + /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code, + * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */ + private Long resolveEntryTypeCvId(final Long provided) { + if (provided != null) { + return provided; + } + return jdbcTemplate.queryForObject( + "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id" + + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'", + Long.class); + } + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { JsonArray log = new JsonArray(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java index 66d369a66..4145533a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java @@ -171,7 +171,7 @@ public class ShareAdditionRequestsApiResource { final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); return this.jdbcTemplate.queryForList(""" SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate", - collected_by_name AS "collectedByName", status + collected_by_name AS "collectedByName", status, note FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id """.formatted(placeholders), ids.toArray()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 448f14fc3..d07a28c4e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -799,6 +799,15 @@ public class ShareAccountDataSerializer { advanceCollectionIds = extractAdvanceCollectionIds(element); if (advanceCollectionIds.isEmpty()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull(); + } else if (advanceCollectionIds.size() > 1) { + // Additional-share tranches fund from exactly one receipt for now -- combining + // instalments across several is the first-application Apply Collection dialog's own + // behaviour, not this one's, until this screen is asked to support it too. + baseDataValidator.reset().parameter(ShareAccountApiConstants.additionalcollectionids_paramname) + .value(advanceCollectionIds.size()) + .failWithCodeAndMessage("share.tranche.single.voucher.only", + "Only one voucher can currently fund an additional-share tranche. " + advanceCollectionIds.size() + + " were submitted."); } else { validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index aaa36da49..590378db0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -429,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ")."); } final List> rows = this.jdbcTemplate.queryForList( - "SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId); + "SELECT d.id, d.status, d.gl_account_id, d.amount, d.txn_id, d.share_account_id, cv.code_value AS entry_type" + + " FROM m_cashier_deposit_request d LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id WHERE d.id = ?", + cashierDepositRequestId); if (rows.isEmpty()) { throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId); } @@ -438,6 +440,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI throw new IllegalStateException( "The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status")); } + if (deposit.get("share_account_id") != null) { + throw new IllegalStateException("This cashier deposit request has already been used for a share application."); + } + // The counter tags what a GL receipt was actually collected for at save time -- a code value + // under GLEntryType (Admin > System > Manage Codes). See + // 0565_convert_entry_type_to_code_value.xml. "General" and a missing tag (rows predating the + // column) are still usable; only an explicit, different purpose blocks it here. + final Object entryType = deposit.get("entry_type"); + if (entryType != null && !"General".equals(entryType) && !"DD Issue".equals(entryType)) { + throw new IllegalStateException( + "This cashier deposit request was recorded as " + entryType + ", not a Demand Draft issuance. Ask the cashier to " + + "correct the entry type, or select a different deposit request."); + } final Object depositGlAccountObj = deposit.get("gl_account_id"); final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null; if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index cf305c1e9..91a0947b6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -626,10 +626,16 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " + "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount " + "from m_cashier_deposit_request cdr " + + "left join m_code_value cv on cv.id = cdr.entry_type_cv_id " + "where cdr.status = 'APPROVED' " + "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) " + "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request " + "where cashier_deposit_request_id is not null) " + // Already spoken for by a share application, or tagged for something else at save + // time -- kept out of the picker rather than only rejected at submit, so the counter + // never even offers the wrong voucher. See 0565_convert_entry_type_to_code_value.xml. + + "and cdr.share_account_id is null " + + "and (cv.code_value IS NULL OR cv.code_value IN ('General', 'DD Issue')) " + "order by cdr.actioned_at desc"; return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData( rs.getLong("id"), diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 0e6164682..4c0994bd0 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -109,4 +109,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml new file mode 100644 index 000000000..57be7341c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml new file mode 100644 index 000000000..67d708297 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml @@ -0,0 +1,124 @@ + + + + + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'GLEntryType', true + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GLEntryType'); + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, v.order_position, true, false + FROM m_code c + CROSS JOIN (VALUES + ('General', 1), + ('Share Collection', 2), + ('DD Issue', 3) + ) AS v(code_value, order_position) + WHERE c.code_name = 'GLEntryType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value + ); + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_cashier_deposit_request d + SET entry_type_cv_id = cv.id + FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'GLEntryType' + AND cv.code_value = CASE d.entry_type + WHEN 'SHARE_COLLECTION' THEN 'Share Collection' + WHEN 'DD_ISSUE' THEN 'DD Issue' + ELSE 'General' + END + AND d.entry_type_cv_id IS NULL; + + UPDATE m_clerk_disbursement_request d + SET entry_type_cv_id = cv.id + FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'GLEntryType' + AND cv.code_value = CASE d.entry_type + WHEN 'SHARE_COLLECTION' THEN 'Share Collection' + WHEN 'DD_ISSUE' THEN 'DD Issue' + ELSE 'General' + END + AND d.entry_type_cv_id IS NULL; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml new file mode 100644 index 000000000..09500687a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + From c1e6e036d011bcbf368a44d1c710e0ae330fedc4 Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 18 Sep 2026 10:29:17 +0530 Subject: [PATCH 50/55] feat(aml): enforce review locking and sync STR case conclusions to linked alerts --- .../portfolio/aml/domain/AmlAlert.java | 34 +++++++++++ .../AmlAlertWritePlatformServiceImpl.java | 61 ++++++++++++++----- .../AmlStrCaseWritePlatformServiceImpl.java | 39 +++++++++++- 3 files changed, 119 insertions(+), 15 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java index ac63dcd4d..feb6deb8f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java @@ -136,6 +136,40 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom { : (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes); } + /** + * Whether this alert is being worked by somebody other than the given officer. + * + *

+ * An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer + * reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction + * would carry two reviews, and whichever was written last would be the one on file. + */ + public boolean isHeldByAnother(final AppUser officer) { + return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null + && !this.reviewedBy.getId().equals(officer.getId()); + } + + /** + * Brings the alert into line with the Principal Officer's conclusion on the case it opened. + * + *

+ * An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is + * the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED, + * the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer + * who cleared it would have no way to see their own conclusion reflected where the alert is worked. + * + *

+ * Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a + * second officer overturning a decision. It is the case this alert was referred into, coming back with its answer. + */ + public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) { + this.reviewedBy = principalOfficer; + this.reviewedOn = LocalDateTime.now(); + this.reviewNotes = notes; + this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name(); + this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name(); + } + public void whitelist(AppUser reviewer, String notes) { this.reviewedBy = reviewer; this.reviewedOn = LocalDateTime.now(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java index 3fe7845f0..5ef1d4cf3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java @@ -51,7 +51,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe @Override public AmlAlert whitelist(Long alertId, String notes) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); alert.whitelist(context.authenticatedUser(), notes); decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true); return alertRepository.save(alert); @@ -59,18 +59,13 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe @Override public AmlStrCase escalate(Long alertId, String notes) { - final AmlAlert alert = loadAlert(alertId); - final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus()); + // loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one. + // An STR is about the transaction, and one transaction gets one case. + final AmlAlert alert = loadForDisposal(alertId); alert.escalate(context.authenticatedUser(), notes); decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true); alertRepository.save(alert); - - // An STR is about the transaction, so an alert already escalated - from the row or from a rule inside it - - // keeps the case it already has rather than opening a second one against the same transaction. - if (wasEscalated) { - return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert)); - } - return openStrCase(alert); + return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert)); } private AmlStrCase openStrCase(final AmlAlert alert) { @@ -92,7 +87,7 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe AmlStrCase strCase = null; for (final Long alertId : alertIds) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); // An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice // under two references, and the older case is the one an officer may already have worked on. @@ -135,14 +130,20 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe */ @Override public AmlAlert reviewRules(final Long alertId, final List ruleCodes, final String disposition, final String notes) { - final AmlAlert alert = loadAlert(alertId); + final AmlAlert alert = loadForDisposal(alertId); final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition); final List findings = alertRuleRepository.findByAlertId(alertId); final Set requested = Set.copyOf(ruleCodes); - final List targeted = findings.stream().filter(f -> requested.contains(f.getRuleCode())).toList(); + // A finding already decided is left alone, for the same reason the alert itself is: the decision recorded + // against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing + // only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than + // silently doing nothing. + final List targeted = findings.stream() + .filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList(); if (targeted.isEmpty()) { - throw new IllegalArgumentException("No matching rules on alert " + alertId); + throw new IllegalArgumentException( + "No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided"); } for (final AmlAlertRule finding : targeted) { finding.decide(decision, context.authenticatedUser(), notes, true); @@ -204,6 +205,38 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe } } + /** + * Loads an alert the current officer is entitled to dispose of. + * + *

+ * An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their + * own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up + * recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes + * it a rule: the screen hides the buttons, but the endpoint is what enforces it. + */ + private AmlAlert loadForDisposal(final Long alertId) { + final AmlAlert alert = loadAlert(alertId); + + // A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged + // transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second + // officer write over it would leave the file saying whatever was submitted last, with the earlier decision and + // the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately, + // through the STR case, where the change is itself recorded. + final AmlAlertStatus status = alert.getStatus(); + if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) { + throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by " + + (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername()) + + " and cannot be decided again"); + } + + final AppUser current = context.authenticatedUser(); + if (alert.isHeldByAnother(current)) { + throw new IllegalStateException("Alert " + alertId + " is being reviewed by " + + alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it"); + } + return alert; + } + private AmlAlert loadAlert(Long alertId) { return alertRepository.findById(alertId) .orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java index 98d3aa15a..9a2c03eff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlStrCaseWritePlatformServiceImpl.java @@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service; import jakarta.persistence.EntityNotFoundException; import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.aml.domain.AmlAlert; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrCase; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert; +import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository; import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion; import org.apache.fineract.useradministration.domain.AppUser; @@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional; public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService { private final AmlStrCaseRepository strCaseRepository; + private final AmlStrCaseAlertRepository strCaseAlertRepository; + private final AmlAlertRepository alertRepository; private final AppUserRepository appUserRepository; private final PlatformSecurityContext context; private final StrSlaCalculator strSlaCalculator; @@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo ? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId()) : null; strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate); - return strCaseRepository.save(strCase); + final AmlStrCase saved = strCaseRepository.save(strCase); + + // The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded + // clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have + // decided one thing and the screen an officer works from would say another. + final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS; + final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear") + + (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes); + for (final AmlAlert alert : alertsOf(saved)) { + alert.applyStrConclusion(suspicious, principalOfficer, note); + alertRepository.save(alert); + } + return saved; + } + + /** + * Every alert the case covers. + * + *

+ * Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and + * concluding it has to answer all of them rather than only the one whose id happens to sit on the case. + */ + private List alertsOf(final AmlStrCase strCase) { + final List covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream() + .map(AmlStrCaseAlert::getAlert).collect(Collectors.toList()); + // Cases opened before the link table existed carry only alert_id. + if (covered.isEmpty() && strCase.getAlert() != null) { + covered.add(strCase.getAlert()); + } + return covered; } @Override From bf52c2cda15aedef5a0f9cbc05a8d03d73a9e8be Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 12:48:22 +0530 Subject: [PATCH 51/55] feat(shares): year-safe voucher numbers, universal Share Collection visibility, unrestricted apply Voucher numbers now embed the year (PREFIX/DDMMYYYY/NNNN) so the same day and month a year apart can never produce the same voucher_no text, which every lookup/dedup across the cash registers matches on literally. /shareadvancecollection/unattributed now lists every unapplied Share Collection receipt across every share account screen, not just ownerless GL deposits -- filtered to rows whose current Entry Type is still Share Collection even after adoption, with the linked member named for transparency. Applying a receipt to a share account no longer blocks on a client mismatch, per product decision. Co-Authored-By: Claude Sonnet 5 --- .../ShareAdvanceCollectionApiResource.java | 226 ++++++++++++------ .../voucher/service/VoucherService.java | 15 +- 2 files changed, 166 insertions(+), 75 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 23004022d..bff7f2df1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -196,11 +196,11 @@ public class ShareAdvanceCollectionApiResource { // to search it up by hand -- it's pulled onto this list itself, the first time anyone asks // for it. Anything not tagged this way (an older row, or one deliberately left General) still // needs the by-voucher search below, same as before. - autoAdoptShareCollectionTagged(clientId); + autoAdoptShareCollectionTagged(); final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," + " cl.display_name AS client_name, " - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," // The GL account this specific receipt's money actually sits in -- its own posting's // credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL // Account picker above can be kept in sync with whichever receipt gets ticked. @@ -222,6 +222,102 @@ public class ShareAdvanceCollectionApiResource { // ══════════════════════════════════════════════════════════════════════ // GET — One unapplied receipt by the voucher number quoted by the member + // ══════════════════════════════════════════════════════════════════════ + // GET /unattributed — share-collection receipts that name no member yet + // ══════════════════════════════════════════════════════════════════════ + /** + * Approved cashier receipts tagged as a share collection that are still waiting to be attached to + * a member. + * + *

+ * A receipt taken straight into a GL account names no member — a GL account is nobody's account — + * so it can never be auto-adopted onto whichever member's screen happens to be open. That rule + * stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on + * purpose and then finding nothing on the Apply screen but "no unapplied receipt for this + * member", with no way to discover that the voucher they just raised is sitting right there. + * + *

+ * This lists exactly those receipts so they can be seen and picked. Picking one still runs the + * same by-voucher adoption a typed voucher number does: a person at the counter deliberately + * attaching a named voucher to a member they can see. The list makes the voucher findable; it + * does not decide whose money it is. + * + *

+ * Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left + * out, so nothing here is something a second member could take twice. A receipt that already + * names a member (a clerk collection resolved from the savings account it debited) is still + * listed here, not only on that member's own screen -- every branch counter runs the same Apply + * screen for whichever member is in front of them, so a voucher must be findable there regardless + * of which member's screen happened to raise or auto-adopt it first. Attaching it from a different + * member's screen still goes through the ordinary by-voucher lookup below, which refuses a + * mismatch rather than silently moving the money -- this list only makes the voucher visible, it + * does not reassign it. + */ + @GET + @Path("/unattributed") + public Response listUnattributed() { + this.context.authenticatedUser(); + + // Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a + // voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen + // is opened, not only once the specific member it names happens to be looked up. + autoAdoptShareCollectionTagged(); + + final List> rows = jdbcTemplate.queryForList( + "SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\"," + + " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount," + + " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\"," + + " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\"" + + " FROM m_cashier_deposit_request d" + + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED'" + + " AND d.client_id IS NULL AND d.share_account_id IS NULL" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)" + + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)" + + " UNION ALL " + + " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\"," + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS \"glAccountNo\"," + // The GL account's own name, not the member's -- "Deposited To" names an + // account on every other row of this list, and a receipt that already names a + // member still needs its own column to say whose it is (below), not to borrow + // this one and read as though a person were a GL account. + + " (SELECT ga.name FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS \"glAccountName\"," + + " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\"," + // Visibly names whose receipt this is -- listing it here does not attribute it + // (attaching it still runs the same by-voucher lookup, which refuses a + // mismatched member), but a counter deciding whether to attach it needs to see + // that up front rather than discovering it only after clicking Attach. + + " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)" + + " || ' (linked to ' || cl.display_name || ')' AS description," + + " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\"" + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + // A voucher's own free-standing collection (raised straight through POST + // /shareadvancecollection, no cashier deposit or clerk request behind it) has + // no entry-type tag at all -- there is nothing else it could be, so it always + // qualifies. One that WAS adopted from a cashier deposit or clerk disbursement + // only stays listed here while that source row is still tagged Share + // Collection today -- a voucher whose entry type reads General (or was + // corrected to General after adoption) belongs on the ordinary voucher + // register, not on this Share Collection-only list, even though the earlier + // adoption already happened and cannot itself be undone here. + + " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no" + + " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no" + + " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id" + + " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id" + + " WHERE c.status = 'UNAPPLIED'" + + " AND ((cdr.id IS NULL AND clr.id IS NULL)" + + " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')" + + " ORDER BY \"txnDate\" DESC"); + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + return Response.ok(gson.toJson(out)).build(); + } + // ══════════════════════════════════════════════════════════════════════ // The voucher arrives as a query parameter because numbers are formatted CR/2108/0001: // as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict @@ -247,7 +343,7 @@ public class ShareAdvanceCollectionApiResource { // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. final List> rows = jdbcTemplate .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + " c.applied_share_account_id " + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); @@ -306,24 +402,15 @@ public class ShareAdvanceCollectionApiResource { @Path("/glaccounts") public Response glAccounts() { this.context.authenticatedUser(); - List> rows = jdbcTemplate.queryForList( - "SELECT DISTINCT ga.id, ga.gl_code, ga.name" - + " FROM acc_gl_account ga" - + " WHERE ga.disabled = false AND ga.account_usage = 1" - + " AND (" - + " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id" - + " AND d.status = 'APPROVED' AND d.share_account_id IS NULL" - + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)" - + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))" - + " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id" - + " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" - + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))" - + " )" - + " ORDER BY ga.gl_code"); - if (rows.isEmpty()) { - rows = jdbcTemplate.queryForList( - "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); - } + // Every postable GL account, same as before this endpoint was ever narrowed to "only + // accounts that currently hold an eligible receipt" -- that narrowing meant to help the + // counter, but it back-fired the moment a genuine voucher sat in a GL account no live + // receipt query happened to match (a fee posted somewhere unusual, a receipt taken + // yesterday), silently dropping it from the list with no way to pick it by hand. The full + // list is always safe to search against; nothing here is auto-picked for the counter (see + // ngOnInit in the dialog) unless it is genuinely the only option. + final List> rows = jdbcTemplate + .queryForList("SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); final Map out = new LinkedHashMap<>(); out.put("glAccounts", rows); return Response.ok(gson.toJson(out)).build(); @@ -458,20 +545,10 @@ public class ShareAdvanceCollectionApiResource { final Map acct = loadAccount(shareAccountId); - // Every receipt belongs to the member: applying one to someone else's account would move - // one member's money into another's share capital. Checked per receipt, since a settlement - // can now carry several. - final Long acctClientId = toLong(acct.get("client_id")); - for (final Map row : collections) { - if (!toLong(row.get("client_id")).equals(acctClientId)) { - throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no") - + " belongs to a different member than share account " + acct.get("account_no") + "."); - } - } - if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) { - throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt " - + chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + "."); - } + // No client-match check here by deliberate choice: any receipt tagged Share Collection can + // be applied to any share account, regardless of which member it was originally recorded + // against. The counter is trusted to attach the right voucher by hand -- nothing here blocks + // it if they choose otherwise. final int statusEnum = toInt(acct.get("status_enum")); if (statusEnum != STATUS_ACTIVE) { @@ -697,18 +774,20 @@ public class ShareAdvanceCollectionApiResource { * A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own -- * buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of * these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER - * auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's - * screen happened to be open when this ran" is a coincidence of timing, not evidence of whose - * money it is, and would silently hand one member's cash to a completely unrelated member simply - * because their Apply Share screen loaded first. A clientless receipt can only ever be claimed - * through the manual by-voucher search, where a person at the counter is actively vouching for it - * against a member they can see, by quoting the specific voucher number the member is holding -- - * a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id - * (a Clerk Collection, whose client comes from the savings account it actually debited -- real - * evidence, not a guess) is still only adopted against a matching client_id, never a different - * member than the one asking. + * auto-adopted for any client -- it can only ever be claimed through the manual by-voucher search + * (including a deliberate click in the Unattributed list), where a person at the counter is + * actively vouching for it against a member they can see. + * + * A row that DOES carry its own client_id (a Clerk Collection, whose client comes from the + * savings account it actually debited -- real evidence, not a guess) is converted here + * unconditionally, regardless of who is asking: the point of this pass is that the voucher + * becomes visible and findable everywhere the moment it is tagged and approved, not only on the + * one screen that happens to belong to the member it names. Converting it does not hand it to + * anybody -- {@link #findByVoucher} still refuses to apply a receipt to an account whose client_id + * does not match, so the member it actually names is protected the same way whether this ran for + * their own screen or a different one. */ - private void autoAdoptShareCollectionTagged(final Long clientIdFilter) { + private void autoAdoptShareCollectionTagged() { final List> deposits = jdbcTemplate.queryForList( "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id," + " d.cashier_id, d.cashier_name, d.description" @@ -720,11 +799,6 @@ public class ShareAdvanceCollectionApiResource { + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"); for (final Map dep : deposits) { final Long resolvedClientId = toLong(dep.get("client_id")); - // Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match - // who is asking -- never adopted for a different member than the one whose screen it is. - if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) { - continue; - } final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); @@ -759,9 +833,6 @@ public class ShareAdvanceCollectionApiResource { } final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, savingsAccountId); - if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) { - continue; - } final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId); final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR"; final Object actionedAt = req.get("actioned_at"); @@ -799,7 +870,7 @@ public class ShareAdvanceCollectionApiResource { final List> deposits = jdbcTemplate .queryForList( "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," - + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id," + + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id, d.description," + " cv.code_value AS entry_type" + " FROM m_cashier_deposit_request d" + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" @@ -816,16 +887,19 @@ public class ShareAdvanceCollectionApiResource { "Voucher " + voucherNo + " has already been applied to a share account."); } // The counter tags what a GL receipt was actually collected for at save time, from the - // GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never - // pick up money that was really meant for something else (a Demand Draft, say) just because - // it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows - // from before this tag existed) are still adoptable -- only an explicit, different purpose - // blocks it. + // GLEntryType code (Admin > System > Manage Codes). Exact match required -- "General" and an + // untagged row used to be let through too, on the reasoning that untyped money carries no + // evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly + // as often share-collection cash the counter forgot to tag, and letting it through here + // quietly rewarded skipping the tag. A voucher only ever becomes reachable for a share + // collection by being tagged for one, on purpose, at save time. final String depositEntryType = (String) dep.get("entry_type"); - if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) { + if (!"Share Collection".equals(depositEntryType)) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", - "Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. " - + "Choose a different voucher, or ask the cashier to correct the entry type."); + "Voucher " + voucherNo + " was recorded as " + + (depositEntryType == null || depositEntryType.isBlank() ? "an untagged entry" : depositEntryType) + + ", not Share Collection. Ask the cashier to correct the entry type at Cashier › Transaction Entry, " + + "then search again."); } if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?", toLong(dep.get("id"))).isEmpty()) { @@ -846,18 +920,25 @@ public class ShareAdvanceCollectionApiResource { final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + // What the cashier actually typed at save time, when they typed anything -- same preference + // as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by + // auto-adoption or by this manual by-voucher lookup. Falls back to a plain label only for a + // deposit nobody described. + final String depDescription = (String) dep.get("description"); + final String note = depDescription != null && !depDescription.isBlank() ? depDescription + : "Adopted from cashier deposit voucher " + voucherNo; + final String receiptNo = nextReceiptNo(); jdbcTemplate.update( "INSERT INTO m_share_advance_collection " + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency, - dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), - "Adopted from cashier deposit voucher " + voucherNo); + dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), note); return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } @@ -892,12 +973,15 @@ public class ShareAdvanceCollectionApiResource { final Map req = requests.get(0); // Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL - // account the counter picked by hand, so it carries the identical ambiguity. + // account the clerk picked by hand, so it carries the identical ambiguity, and the identical + // exact-match requirement: a voucher only becomes reachable for a share collection by being + // tagged Share Collection at save time, "General" and untagged included. final String reqEntryType = (String) req.get("entry_type"); - if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) { + if (!"Share Collection".equals(reqEntryType)) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", - "Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. " - + "Choose a different voucher, or ask the clerk to correct the entry type."); + "Voucher " + voucherNo + " was recorded as " + + (reqEntryType == null || reqEntryType.isBlank() ? "an untagged entry" : reqEntryType) + + ", not Share Collection. Ask the clerk to correct the entry type on this request, then search again."); } final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); @@ -930,7 +1014,7 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index 0fbbf4813..be8aa7f6b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -38,11 +38,18 @@ import org.springframework.stereotype.Service; /** * Central voucher register (m_voucher). Every maker-checker cash transaction * (clerk withdrawal request, cashier deposit request) gets a voucher number in - * the form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and - * DR/2108/0001 for a withdrawal on 21 August. + * the form PREFIX/DDMMYYYY/NNNN — for example CR/21082026/0001 for a deposit and + * DR/21082026/0001 for a withdrawal on 21 August 2026. * * The trailing counter restarts at 0001 every day, independently for each * prefix, so a deposit and a withdrawal on the same day never share a number. + * The year is part of the printed number (not just of the underlying + * m_voucher_sequence.series_date counter) precisely so the same calendar + * day-of-year in two different years can never produce the same voucher_no + * text — every lookup and dedup in this system (share-advance adoption, + * "already applied" checks, unattributed-receipt listings) matches on that + * text, so a same-text collision a year apart would silently mismatch two + * completely unrelated vouchers. * The series is bank-wide rather than per-branch: the number names the slip * side and the day, and nothing else. The office that raised the voucher is * still recorded, in m_voucher.branch_office_id, so a per-branch register @@ -108,7 +115,7 @@ public class VoucherService { /** A savings movement, keyed by its m_savings_account_transaction id. */ public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; - private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM"); + private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMMyyyy"); private final JdbcTemplate jdbcTemplate; @@ -188,7 +195,7 @@ public class VoucherService { /** * Creates a voucher row and returns the formatted voucher number, e.g. - * "CR/2108/0001". The office that handled the cash is taken from the acting + * "CR/21082026/0001". The office that handled the cash is taken from the acting * user rather than from any branch the screen happens to have selected, and * is stored alongside the voucher instead of being printed into its number. */ From e206536a6b19a9a9f234493d4a3f50a60d23773d Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:02:55 +0530 Subject: [PATCH 52/55] feat(shares): reports suite, GL transaction fixes, strict entry-type enforcement - Add ShareReportsApiResource (final balance, account statement, issued/status/ nominal/share-wise/guarantor/member-interest reports, enquiry, payment certificate, share detail, dividend statement, folio statement, distinctive numbers, area-wise, mailing list) and ShareTransferApiResource, applying the paid-up-capital predicate everywhere so an unsettled application never counts as issued capital - Add share transfer migration (0567) - Enforce the original application's collection before a further tranche can be applied (ShareAccountDataSerializer), matching the same gate on screen - Fix postGlOffsetEntry silently discarding the real GL transaction id on the clerk-collection fallback posting path, leaving gl_transaction_id blank and the receipt's GL-detail popup empty - Add gl_code to the by-voucher and adoption responses so the GL Account picker on screen follows whichever receipt was found or adopted - Require an exact "Share Collection" entry-type tag to adopt a voucher -- "General" and untagged rows no longer qualify Co-Authored-By: Claude Sonnet 5 --- .../ShareAdvanceCollectionApiResource.java | 28 +- .../ClerkDisbursementRequestApiResource.java | 22 +- .../api/ShareReportsApiResource.java | 2221 +++++++++++++++++ .../api/ShareTransferApiResource.java | 496 ++++ .../ShareAccountDataSerializer.java | 10 + .../tenant/final-changelog-tenant.xml | 1 + .../parts/0567_create_share_transfer.xml | 93 + 7 files changed, 2865 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index bff7f2df1..d2a8a5be9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -341,10 +341,18 @@ public class ShareAdvanceCollectionApiResource { this.context.authenticatedUser(); // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. + // gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen + // follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a + // receipt resolved through this search must feed that picker exactly the same as one resolved + // from the member's own list, or the picker is silently left on "Select GL account…" for + // every voucher found this way. final List> rows = jdbcTemplate .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," - + " c.applied_share_account_id " + + " c.applied_share_account_id," + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); @@ -938,7 +946,14 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id," + // Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly + // adopted voucher must feed the on-screen GL Account picker exactly like one + // that was already sitting on the member's list, or the picker is silently + // left on "Select GL account…" for every voucher adopted this way. + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } @@ -1014,7 +1029,14 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id," + // Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly + // adopted voucher must feed the on-screen GL Account picker exactly like one + // that was already sitting on the member's list, or the picker is silently + // left on "Select GL account…" for every voucher adopted this way. + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index a250b0e45..08d596d26 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -746,8 +746,14 @@ public class ClerkDisbursementRequestApiResource { } else { final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); if (cashVaultGlId != null) { - postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, + approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, officeId, currencyCode, txnDateStr, description); + } else if (!withdrawalSavingsTxnRefs.isEmpty()) { + // No bridging entry was needed: the withdrawal's own auto-posted credit + // leg already lands on the target GL directly (the savings product's own + // payment-type mapping points there). That withdrawal's own transaction is + // therefore the real posting -- there is no second one to remember instead. + approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0); } } } @@ -1440,7 +1446,15 @@ public class ClerkDisbursementRequestApiResource { return null; } - private void postGlOffsetEntry( + /** + * @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs + * to remember which posting was theirs — the collection fallback below, most importantly + * — is not left with nothing to record. Returning it here rather than discarding it, as + * this method used to, was the entire reason {@code gl_transaction_id} stayed blank on + * every clerk collection that fell back to this path: nothing downstream (Share Advance + * Collection's GL-detail popup among them) had anything to look the posting up by. + */ + private String postGlOffsetEntry( final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final Long officeId, final String currencyCode, final String txnDateStr, final String comments) { @@ -1476,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource { .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); // The disbursement register raises its own voucher; suppress the journal command's own. - voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); + return result != null ? result.getTransactionId() : null; } // ── Combined GL entry helpers ───────────────────────────────────────────── diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java new file mode 100644 index 000000000..1ac70b446 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java @@ -0,0 +1,2221 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The share reporting set the Share menu opens: a member's closing share capital, one account's + * statement, what was allotted in a period, and where every account currently stands. + * + *

+ * All four read the same two tables the rest of the share module writes to - {@code m_share_account} + * and {@code m_share_account_transactions} - so a figure here is the figure the ledger has, never a + * separately-maintained total that can drift from it. Shares held is always derived by walking the + * transactions (purchases less redemptions) rather than reading + * {@code m_share_account.total_approved_shares}, because that column is a running total the account + * carries today and cannot answer "as on" a date in the past, which is the whole point of a balance + * report. + * + *

+ * Only settled movements count: a transaction is included when it is approved + * ({@code status_enum = 300}) and still active, so a tranche a checker has not approved yet never + * inflates a member's holding or the society's issued capital. + * + *

+ * Read-only; nothing here writes. + */ +@Path("/v1/sharereports") +@Component +@Tag(name = "Share Reports", description = "Share capital balances, account statements, allotments and status summaries.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareReportsApiResource { + + // ── m_share_account_transactions.type_enum (PurchasedSharesStatusType) ── + private static final int TYPE_PURCHASED = 500; + private static final int TYPE_REDEEMED = 600; + + // ── m_share_account_transactions.status_enum ─────────────────────────── + private static final int TXN_APPROVED = 300; + + // ── m_share_account.status_enum ──────────────────────────────────────── + private static final int ACCOUNT_REJECTED = 500; + private static final int ACCOUNT_CLOSED = 600; + + /** + * Why a share account's holding reads the way it does, as a value rather than something a reader + * has to infer from a zero. + * + *

+ * A nil holding is the most over-loaded figure on any share register: a closed account, an active + * account whose money was never collected, and an account redeemed down to nothing all print the + * same "0", and they mean completely different things to whoever is reading. Deriving the reason + * once here, in SQL, keeps every report telling the member the same story, and stops a closed + * account from looking open on one screen and closed on the next. + * + *

    + *
  • REJECTED — the application never became an account.
  • + *
  • NOT_ACTIVE — submitted or approved, not yet activated.
  • + *
  • CLOSED — the member has left; the nil holding is final and correct.
  • + *
  • UNPAID — active with shares allotted, but the collection was never applied. The + * shares exist on paper only, which is exactly why they are in no capital figure.
  • + *
  • NIL — active and paid for, but redeemed back down to nothing.
  • + *
  • HOLDING — the ordinary case: paid-up shares in hand.
  • + *
+ * + *

+ * Expects {@code sa} to be the share account and {@code bal.shares_held} the paid-up holding the + * query has already worked out, so it reads the same balance the row prints rather than a second + * opinion of its own. + */ + private static final String HOLDING_STATE_SQL = """ + CASE + WHEN sa.status_enum = %d THEN 'REJECTED' + WHEN sa.status_enum = %d THEN 'CLOSED' + WHEN sa.status_enum <> 300 THEN 'NOT_ACTIVE' + WHEN COALESCE(bal.shares_held, 0) > 0 THEN 'HOLDING' + WHEN COALESCE(sa.total_approved_shares, 0) > 0 + AND COALESCE(sa.collection_applied, false) = false THEN 'UNPAID' + ELSE 'NIL' + END AS "holdingState", + CASE + WHEN sa.status_enum = 300 AND COALESCE(sa.collection_applied, false) = false + THEN COALESCE(sa.total_approved_shares, 0) + ELSE 0 + END AS "unpaidShares", + """.formatted(ACCOUNT_REJECTED, ACCOUNT_CLOSED); + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ══════════════════════════════════════════════════════════════════════ + // GET /final-balance — closing share capital per member, as on a date + // ══════════════════════════════════════════════════════════════════════ + /** + * Every member's shareholding and its value as on a date, which is what a balance sheet's Share + * Capital line has to reconcile to. + * + * @param asOnDate + * counts only movements up to and including this date; omit for the position today + * @param officeId + * a single branch, for a branch-scoped login + * @param productId + * a single share class, since a society can issue more than one + * @param includeZero + * keeps members whose holding has fallen to nil (fully redeemed, or closed) on the + * report; off by default, because a closing-balance report is normally read as the + * list of who currently holds capital + */ + @GET + @Path("/final-balance") + @Operation(summary = "Share capital balance per member as on a date", + description = "Shares held and their value per share account, derived from settled purchases less redemptions up to the given date.") + public Response finalBalance(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId, @QueryParam("includeZero") final Boolean includeZero) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_purchased, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_redeemed, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.amount, 0) ELSE 0 END) AS amount_subscribed, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.amount, 0) ELSE 0 END) AS amount_redeemed, + MIN(t.transaction_date) AS first_movement, + MAX(t.transaction_date) AS last_movement + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, + TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(bal.shares_purchased, 0) AS "sharesPurchased", + COALESCE(bal.shares_redeemed, 0) AS "sharesRedeemed", + COALESCE(bal.amount_subscribed, 0) AS "amountSubscribed", + COALESCE(bal.amount_redeemed, 0) AS "amountRedeemed", + to_char(bal.first_movement, 'YYYY-MM-DD') AS "firstMovementDate", + to_char(bal.last_movement, 'YYYY-MM-DD') AS "lastMovementDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + sql.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """); + + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (!Boolean.TRUE.equals(includeZero)) { + sql.append(" AND COALESCE(bal.shares_held, 0) > 0 "); + } + sql.append(" ORDER BY o.name, c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + // Totalled here rather than on the client: the client only ever holds the page it is + // showing, and a Share Capital total that silently means "of the rows on screen" is exactly + // the number someone would carry into a balance sheet by mistake. + long totalShares = 0L; + java.math.BigDecimal totalValue = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalSubscribed = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalRedeemed = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + totalShares += toLong(row.get("sharesHeld")); + totalValue = totalValue.add(toDecimal(row.get("shareValue"))); + totalSubscribed = totalSubscribed.add(toDecimal(row.get("amountSubscribed"))); + totalRedeemed = totalRedeemed.add(toDecimal(row.get("amountRedeemed"))); + } + + final Map out = new LinkedHashMap<>(); + // Allotted but never paid for. Excluded from the balance above, because share capital is + // what the society has received, not what it has promised to issue — but counted and + // reported here, since a total that silently drops accounts leaves a reader wondering which + // ones, and an unpaid allotment sitting for months is itself the thing worth chasing. + final Map unpaid = this.jdbcTemplate.queryForMap(""" + SELECT COUNT(*) AS "accountCount", + COALESCE(SUM(COALESCE(t.total_shares, 0)), 0) AS "shares", + COALESCE(SUM(COALESCE(t.amount, 0)), 0) AS "amount" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND t.funding_source IS NULL AND COALESCE(sa.collection_applied, false) = false + AND sa.status_enum <> 500 + """.formatted(TXN_APPROVED, TYPE_PURCHASED)); + + out.put("asOnDate", asOnDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("sharesHeld", totalShares); + totals.put("shareValue", totalValue); + totals.put("amountSubscribed", totalSubscribed); + totals.put("amountRedeemed", totalRedeemed); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + out.put("unpaidAllotments", unpaid); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /account-statement/{shareAccountId} — one account, movement by movement + // ══════════════════════════════════════════════════════════════════════ + /** + * One share account's statement: every settled movement in date order with the holding it left + * behind, plus the header a printed statement needs. + * + *

+ * The running balance is computed over the whole life of the account and only then filtered to + * the requested period, so the first line of a mid-year statement opens at the holding the + * member actually had on that date rather than at zero. + */ + @GET + @Path("/account-statement/{shareAccountId}") + @Operation(summary = "Share account statement", + description = "Header plus every settled purchase and redemption in date order, with the running shareholding after each.") + public Response accountStatement(@PathParam("shareAccountId") final Long shareAccountId, + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { + this.context.authenticatedUser(); + + final List> headers = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + sp.unit_price AS "unitPrice", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate", + sav.account_no AS "linkedSavingsAccountNo" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + WHERE sa.id = ? + """, shareAccountId); + if (headers.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.account.not.found", + "Share account " + shareAccountId + " was not found."); + } + + final List> lines = this.jdbcTemplate.queryForList(""" + SELECT * FROM ( + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + t.status_enum AS "statusEnum", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesIn", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesOut", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.amount_paid AS "amountPaid", + t.resolution_number AS "resolutionNumber", + to_char(t.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + t.funding_source AS "fundingSource", + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) + WHEN t.type_enum = %d THEN -COALESCE(t.total_shares, 0) + ELSE 0 END) + OVER (ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "balanceShares", + t.transaction_date AS raw_date + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = %d + AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + ) s + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, + TYPE_REDEEMED) + periodFilter(fromDate, toDate) + " ORDER BY raw_date, \"transactionId\" ", + periodArgs(shareAccountId, fromDate, toDate)); + + // The value a line leaves behind, at the unit price the account's product carries. Computed + // here so the statement and the balance report cannot disagree about what a holding is worth. + final java.math.BigDecimal unitPrice = toDecimal(headers.get(0).get("unitPrice")); + for (final Map line : lines) { + line.remove("raw_date"); + line.put("balanceValue", java.math.BigDecimal.valueOf(toLong(line.get("balanceShares"))).multiply(unitPrice)); + } + + final Map out = new LinkedHashMap<>(); + out.put("account", headers.get(0)); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("lines", lines); + final Map totals = new LinkedHashMap<>(); + long in = 0L; + long outShares = 0L; + java.math.BigDecimal amountIn = java.math.BigDecimal.ZERO; + java.math.BigDecimal amountOut = java.math.BigDecimal.ZERO; + for (final Map line : lines) { + final long sharesIn = toLong(line.get("sharesIn")); + final long sharesOut = toLong(line.get("sharesOut")); + in += sharesIn; + outShares += sharesOut; + if (sharesIn > 0) { + amountIn = amountIn.add(toDecimal(line.get("amount"))); + } + if (sharesOut > 0) { + amountOut = amountOut.add(toDecimal(line.get("amount"))); + } + } + totals.put("sharesIn", in); + totals.put("sharesOut", outShares); + totals.put("amountIn", amountIn); + totals.put("amountOut", amountOut); + totals.put("closingShares", lines.isEmpty() ? 0L : toLong(lines.get(lines.size() - 1).get("balanceShares"))); + totals.put("closingValue", + java.math.BigDecimal.valueOf(lines.isEmpty() ? 0L : toLong(lines.get(lines.size() - 1).get("balanceShares"))) + .multiply(unitPrice)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-issued — allotments made in a period + // ══════════════════════════════════════════════════════════════════════ + /** + * The allotment register: every tranche of shares issued in a period, with the resolution that + * authorised it and how the member paid for it. + * + *

+ * One row per tranche rather than per member, because a member who bought three times in the + * year was allotted three times and the register records allotments. + */ + @GET + @Path("/share-issued") + @Operation(summary = "Shares issued in a period", + description = "One row per allotment tranche: member, shares, value, board resolution and funding route.") + public Response shareIssued(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId, + @QueryParam("status") final Integer status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "issueDate", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.status_enum AS "statusEnum", + t.resolution_number AS "resolutionNumber", + to_char(t.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + t.funding_source AS "fundingSource", + sa.status_enum AS "accountStatusEnum", + -- Allotted is not the same as paid for. An opening tranche is only paid-up + -- once the member's collection has been applied to the account; an + -- additional tranche was paid for at its own approval. An unpaid allotment + -- still belongs in the register -- it was allotted -- but it is not capital + -- the society has received, so it is flagged rather than quietly counted. + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE t.is_active = true AND t.type_enum = %d + """.formatted(TYPE_PURCHASED)); + + final List args = new ArrayList<>(); + if (fromDate != null && !fromDate.isBlank()) { + sql.append(" AND t.transaction_date >= CAST(? AS date) "); + args.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + sql.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(toDate); + } + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + // Defaults to settled allotments only. A pending tranche is a request, not an issue, and + // counting it here would overstate issued capital -- but it is reachable on purpose, since + // "what is waiting to be allotted" is a question this register is also asked. + if (status != null) { + sql.append(" AND t.status_enum = ? "); + args.add(status); + } else { + sql.append(" AND t.status_enum = ").append(TXN_APPROVED).append(" "); + } + sql.append(" ORDER BY t.transaction_date DESC, t.id DESC "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long totalShares = 0L; + long unpaidShares = 0L; + java.math.BigDecimal totalAmount = java.math.BigDecimal.ZERO; + java.math.BigDecimal unpaidAmount = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalCharges = java.math.BigDecimal.ZERO; + final java.util.Set members = new java.util.HashSet<>(); + for (final Map row : rows) { + totalShares += toLong(row.get("shares")); + totalCharges = totalCharges.add(toDecimal(row.get("chargeAmount"))); + members.add(row.get("clientId")); + // Capital raised counts only what was actually paid for. An allotment whose collection + // has never been applied is shares issued on paper against money the society has not + // received, and adding it here is how a Share Capital total drifts above what the + // ledger says. + if (Boolean.TRUE.equals(row.get("paidUp"))) { + totalAmount = totalAmount.add(toDecimal(row.get("amount"))); + } else { + unpaidShares += toLong(row.get("shares")); + unpaidAmount = unpaidAmount.add(toDecimal(row.get("amount"))); + } + } + + final Map out = new LinkedHashMap<>(); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("trancheCount", rows.size()); + totals.put("memberCount", members.size()); + totals.put("unpaidShares", unpaidShares); + totals.put("unpaidAmount", unpaidAmount); + totals.put("shares", totalShares); + totals.put("amount", totalAmount); + totals.put("charges", totalCharges); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /status-summary — where every share account stands + // ══════════════════════════════════════════════════════════════════════ + /** + * Share accounts counted by status and share class, with the accounts themselves alongside, so + * the summary can be read and then drilled into without a second screen. + */ + @GET + @Path("/status-summary") + @Operation(summary = "Share accounts by status", + description = "Account and member counts, shares and value grouped by account status and share product, plus the underlying accounts.") + public Response statusSummary(@QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final StringBuilder where = new StringBuilder(" WHERE 1 = 1 "); + final List args = new ArrayList<>(); + if (officeId != null) { + where.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + where.append(" AND sp.id = ? "); + args.add(productId); + } + + final List> groups = this.jdbcTemplate.queryForList(""" + SELECT sa.status_enum AS "statusEnum", + sp.id AS "productId", + sp.name AS "productName", + COUNT(*) AS "accountCount", + COUNT(DISTINCT sa.client_id) AS "memberCount", + SUM(COALESCE(sa.total_approved_shares, 0)) AS "approvedShares", + SUM(COALESCE(sa.total_pending_shares, 0)) AS "pendingShares", + SUM(COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0)) AS "shareValue" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + """ + where + """ + GROUP BY sa.status_enum, sp.id, sp.name + ORDER BY sa.status_enum, sp.name + """, args.toArray()); + + final List> accounts = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + COALESCE(sa.total_approved_shares, 0) AS "approvedShares", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.rejected_date, 'YYYY-MM-DD') AS "rejectedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + """ + where + """ + ORDER BY sa.status_enum, c.display_name, sa.account_no + """, args.toArray()); + + final Map out = new LinkedHashMap<>(); + out.put("groups", groups); + out.put("accounts", accounts); + final Map totals = new LinkedHashMap<>(); + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + final java.util.Set members = new java.util.HashSet<>(); + for (final Map row : accounts) { + shares += toLong(row.get("approvedShares")); + value = value.add(toDecimal(row.get("shareValue"))); + members.add(row.get("clientId")); + } + totals.put("accountCount", accounts.size()); + totals.put("memberCount", members.size()); + totals.put("approvedShares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /nominal-shares — holdings against the share class's own rules + // ══════════════════════════════════════════════════════════════════════ + /** + * Nominal Share Report: every member's holding measured against the three numbers the share + * product itself sets — nominal, minimum and maximum shares per client — and valued at face. + * + *

+ * All three readings of "nominal" a co-operative uses are answered together, because they are + * the same figure looked at from different sides and splitting them across screens would mean + * reading two to answer one question: + * + *

    + *
  • Nominal (face) value — shares held at the product's unit price, which is what the + * Share Capital line is stated at.
  • + *
  • Against the product's nominal holding — the shares the class expects a member to + * take, and the shortfall or excess against it.
  • + *
  • Nominal vs regular membership — a member holding less than the nominal subscription + * has not taken up full membership, which is what "nominal member" means in a co-operative and + * what decides whether they vote.
  • + *
+ * + *

+ * Compliance is the point of the report, so a holding outside the class's own minimum or maximum + * is flagged rather than left for the reader to spot by comparing two columns. + */ + @GET + @Path("/nominal-shares") + @Operation(summary = "Nominal share holdings against product rules", + description = "Shares held per member at face value, measured against the share class's nominal, minimum and maximum holding, with membership class and any breach flagged.") + public Response nominalShares(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "faceValue", + sp.nominal_client_shares AS "nominalShares", + sp.minimum_client_shares AS "minimumShares", + sp.maximum_client_shares AS "maximumShares", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "nominalValue", + COALESCE(bal.shares_held, 0) - COALESCE(sp.nominal_client_shares, 0) AS "againstNominal", + -- Compliance and membership class are judgements about a live member, so an + -- account that is closed, rejected or not yet active is excused from both + -- rather than reported as breaching a minimum it is no longer subject to. + CASE + WHEN sa.status_enum <> 300 THEN 'NOT_APPLICABLE' + WHEN COALESCE(bal.shares_held, 0) = 0 THEN 'NO_HOLDING' + WHEN sp.minimum_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) < sp.minimum_client_shares THEN 'BELOW_MINIMUM' + WHEN sp.maximum_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) > sp.maximum_client_shares THEN 'ABOVE_MAXIMUM' + ELSE 'WITHIN_LIMITS' + END AS "complianceStatus", + CASE + WHEN sa.status_enum <> 300 THEN 'NOT_APPLICABLE' + WHEN COALESCE(bal.shares_held, 0) = 0 THEN 'NONE' + WHEN sp.nominal_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) < sp.nominal_client_shares THEN 'NOMINAL' + ELSE 'REGULAR' + END AS "membershipClass", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + sql.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + sql.append(" ORDER BY c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long held = 0L; + java.math.BigDecimal nominalValue = java.math.BigDecimal.ZERO; + int belowMinimum = 0; + int aboveMaximum = 0; + int nominalMembers = 0; + int regularMembers = 0; + for (final Map row : rows) { + held += toLong(row.get("sharesHeld")); + nominalValue = nominalValue.add(toDecimal(row.get("nominalValue"))); + final Object compliance = row.get("complianceStatus"); + if ("BELOW_MINIMUM".equals(compliance)) { + belowMinimum++; + } else if ("ABOVE_MAXIMUM".equals(compliance)) { + aboveMaximum++; + } + final Object membership = row.get("membershipClass"); + if ("NOMINAL".equals(membership)) { + nominalMembers++; + } else if ("REGULAR".equals(membership)) { + regularMembers++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("asOnDate", asOnDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", rows.size()); + totals.put("sharesHeld", held); + totals.put("nominalValue", nominalValue); + totals.put("belowMinimum", belowMinimum); + totals.put("aboveMaximum", aboveMaximum); + totals.put("nominalMembers", nominalMembers); + totals.put("regularMembers", regularMembers); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-wise — how the shareholding is spread + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Wise Report: the shareholding pattern — how many members sit in each holding band, and + * how the capital splits by share class. + * + *

+ * The bands are what a board or the Registrar reads to see concentration: a society where three + * members hold half the capital reads very differently from one where five hundred hold it + * evenly, and neither is visible from a members-and-total figure alone. + * + *

+ * Bands are fixed rather than configurable on purpose — a distribution that moves its own + * boundaries between runs cannot be compared with last year's, which is most of what it is for. + */ + @GET + @Path("/share-wise") + @Operation(summary = "Shareholding pattern by band and share class", + description = "Members and capital grouped into holding bands, plus the per-product split, with each band's share of total capital.") + public Response shareWise(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder holdings = new StringBuilder(""" + SELECT sa.id AS account_id, + sa.client_id, + sp.id AS product_id, + sp.name AS product_name, + COALESCE(bal.shares_held, 0) AS shares_held, + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS share_value + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + holdings.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE COALESCE(bal.shares_held, 0) > 0 + """); + if (officeId != null) { + holdings.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + holdings.append(" AND sp.id = ? "); + args.add(productId); + } + + // Bands are assigned in SQL and counted in SQL, so an empty band still has to be added back + // in Java below: a distribution that silently drops "0 members hold 501-1000" reads as if + // the band did not exist rather than as if nobody is in it. + final List> bands = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT CASE + WHEN shares_held <= 50 THEN '1 - 50' + WHEN shares_held <= 100 THEN '51 - 100' + WHEN shares_held <= 500 THEN '101 - 500' + WHEN shares_held <= 1000 THEN '501 - 1000' + ELSE '1001 and above' + END AS "band", + CASE + WHEN shares_held <= 50 THEN 1 + WHEN shares_held <= 100 THEN 2 + WHEN shares_held <= 500 THEN 3 + WHEN shares_held <= 1000 THEN 4 + ELSE 5 + END AS "bandOrder", + COUNT(*) AS "accountCount", + COUNT(DISTINCT client_id) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY 1, 2 + ORDER BY 2 + """, args.toArray()); + + final List> byProduct = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT product_id AS "productId", + product_name AS "productName", + COUNT(*) AS "accountCount", + COUNT(DISTINCT client_id) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY product_id, product_name + ORDER BY product_name + """, args.toArray()); + + long totalShares = 0L; + java.math.BigDecimal totalValue = java.math.BigDecimal.ZERO; + for (final Map row : byProduct) { + totalShares += toLong(row.get("shares")); + totalValue = totalValue.add(toDecimal(row.get("shareValue"))); + } + + // Every band, present or not, in order — and each one's share of capital, computed here + // because a percentage of a total the client only partly holds is a percentage of nothing. + final String[] bandNames = { "1 - 50", "51 - 100", "101 - 500", "501 - 1000", "1001 and above" }; + final List> fullBands = new ArrayList<>(); + for (int i = 0; i < bandNames.length; i++) { + final String name = bandNames[i]; + Map found = null; + for (final Map band : bands) { + if (name.equals(band.get("band"))) { + found = band; + break; + } + } + final Map band = new LinkedHashMap<>(); + band.put("band", name); + band.put("bandOrder", i + 1); + band.put("accountCount", found == null ? 0 : found.get("accountCount")); + band.put("memberCount", found == null ? 0 : found.get("memberCount")); + band.put("shares", found == null ? 0 : found.get("shares")); + band.put("shareValue", found == null ? java.math.BigDecimal.ZERO : found.get("shareValue")); + final java.math.BigDecimal bandValue = found == null ? java.math.BigDecimal.ZERO : toDecimal(found.get("shareValue")); + band.put("capitalShare", totalValue.signum() == 0 ? java.math.BigDecimal.ZERO + : bandValue.multiply(java.math.BigDecimal.valueOf(100)).divide(totalValue, 2, java.math.RoundingMode.HALF_UP)); + fullBands.add(band); + } + + final Map out = new LinkedHashMap<>(); + out.put("asOnDate", asOnDate); + out.put("bands", fullBands); + out.put("byProduct", byProduct); + final Map totals = new LinkedHashMap<>(); + totals.put("shares", totalShares); + totals.put("shareValue", totalValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /guarantor-detail — who stands surety, and what they hold + // ══════════════════════════════════════════════════════════════════════ + /** + * Guarantor Detail: every guarantor standing on a live loan, with the shareholding they hold in + * the society. + * + *

+ * The two belong on one line because a co-operative accepts a member as surety on the strength + * of their stake in it: a guarantor whose own shareholding has since been redeemed is exactly + * the exposure this report exists to surface, and neither the loan screen nor the share screen + * shows it alone. + * + *

+ * External guarantors (a non-member standing surety) are included and marked as such, with no + * shareholding to show - they are part of the exposure whether or not they hold shares. + */ + @GET + @Path("/guarantor-detail") + @Operation(summary = "Loan guarantors and their shareholding", + description = "Every active guarantor with the loan they stand on and the shares they hold, member and external alike.") + public Response guarantorDetail(@QueryParam("officeId") final Long officeId, @QueryParam("onlyMembers") final Boolean onlyMembers) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT g.id AS "guarantorId", + g.loan_id AS "loanId", + l.account_no AS "loanAccountNo", + l.loan_status_id AS "loanStatusId", + l.principal_amount AS "loanPrincipal", + COALESCE(l.principal_outstanding_derived, 0) AS "principalOutstanding", + COALESCE(l.total_outstanding_derived, 0) AS "totalOutstanding", + borrower.display_name AS "borrowerName", + borrower.account_no AS "borrowerCif", + g.type_enum AS "guarantorTypeEnum", + CASE WHEN g.type_enum = 1 THEN 'MEMBER' ELSE 'EXTERNAL' END AS "guarantorType", + COALESCE(gc.display_name, TRIM(CONCAT(g.firstname, ' ', g.lastname))) AS "guarantorName", + gc.account_no AS "guarantorCif", + gc.id AS "guarantorClientId", + go.name AS "guarantorOfficeName", + g.mobile_number AS "guarantorMobile", + sh.share_account_id AS "shareAccountId", + sh.account_no AS "shareAccountNo", + sh.product_name AS "shareProductName", + COALESCE(sh.shares_held, 0) AS "sharesHeld", + COALESCE(sh.share_value, 0) AS "shareValue" + FROM m_guarantor g + JOIN m_loan l ON l.id = g.loan_id + JOIN m_client borrower ON borrower.id = l.client_id + LEFT JOIN m_client gc ON gc.id = g.entity_id AND g.type_enum = 1 + LEFT JOIN m_office go ON go.id = gc.office_id + LEFT JOIN ( + SELECT sa.client_id, + MIN(sa.id) AS share_account_id, + MIN(sa.account_no) AS account_no, + MIN(sp.name) AS product_name, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + GROUP BY sa.client_id + ) sh ON sh.client_id = gc.id + WHERE g.is_active = true + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND go.id = ? "); + args.add(officeId); + } + if (Boolean.TRUE.equals(onlyMembers)) { + sql.append(" AND g.type_enum = 1 "); + } + sql.append(" ORDER BY borrower.display_name, l.account_no, g.id "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + int memberGuarantors = 0; + int externalGuarantors = 0; + int withoutShares = 0; + java.math.BigDecimal guaranteedOutstanding = java.math.BigDecimal.ZERO; + java.math.BigDecimal guarantorShareValue = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + if ("MEMBER".equals(row.get("guarantorType"))) { + memberGuarantors++; + if (toLong(row.get("sharesHeld")) == 0L) { + withoutShares++; + } + } else { + externalGuarantors++; + } + guaranteedOutstanding = guaranteedOutstanding.add(toDecimal(row.get("totalOutstanding"))); + guarantorShareValue = guarantorShareValue.add(toDecimal(row.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("guarantorCount", rows.size()); + totals.put("memberGuarantors", memberGuarantors); + totals.put("externalGuarantors", externalGuarantors); + totals.put("membersWithoutShares", withoutShares); + totals.put("guaranteedOutstanding", guaranteedOutstanding); + totals.put("guarantorShareValue", guarantorShareValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /member-loan-interest — interest earned from the members who hold shares + // ══════════════════════════════════════════════════════════════════════ + /** + * Total Interest Received and Receivable, for the members who hold share capital. + * + *

+ * Shares themselves earn dividend, not interest - this report is the other direction: what the + * society has earned in interest from the borrowings of the members who fund it. A co-operative + * reads the two together, because a member's stake and what their lending returns is the case + * for admitting and keeping them. + * + *

+ * Received and receivable are kept as separate columns rather than summed: interest charged but + * not yet collected is not income in hand, and a single "interest" figure hides exactly that. + */ + @GET + @Path("/member-loan-interest") + @Operation(summary = "Interest received and receivable from shareholding members", + description = "Per member: shares held, and across their loans the interest charged, received, outstanding, waived and written off.") + public Response memberLoanInterest(@QueryParam("officeId") final Long officeId, + @QueryParam("includeClosedLoans") final Boolean includeClosedLoans) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT c.id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + COALESCE(sh.shares_held, 0) AS "sharesHeld", + COALESCE(sh.share_value, 0) AS "shareValue", + COUNT(l.id) AS "loanCount", + COALESCE(SUM(l.principal_amount), 0) AS "principalLent", + COALESCE(SUM(l.principal_outstanding_derived), 0) AS "principalOutstanding", + COALESCE(SUM(l.interest_charged_derived), 0) AS "interestCharged", + COALESCE(SUM(l.interest_repaid_derived), 0) AS "interestReceived", + COALESCE(SUM(l.interest_outstanding_derived), 0) AS "interestReceivable", + COALESCE(SUM(l.interest_waived_derived), 0) AS "interestWaived", + COALESCE(SUM(l.interest_writtenoff_derived), 0) AS "interestWrittenOff" + FROM m_client c + JOIN ( + SELECT sa.client_id, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + GROUP BY sa.client_id + ) sh ON sh.client_id = c.id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_loan l ON l.client_id = c.id + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + // Live loans only by default (300 = active, 700 = overpaid). A closed loan's interest is + // history rather than a position, and mixing the two makes "receivable" unreadable. + if (!Boolean.TRUE.equals(includeClosedLoans)) { + sql.append(" AND l.loan_status_id IN (300, 700) "); + } + sql.append(" WHERE 1 = 1 "); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + sql.append(""" + GROUP BY c.id, c.account_no, c.display_name, o.name, sh.shares_held, sh.share_value + ORDER BY c.display_name + """); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + java.math.BigDecimal charged = java.math.BigDecimal.ZERO; + java.math.BigDecimal received = java.math.BigDecimal.ZERO; + java.math.BigDecimal receivable = java.math.BigDecimal.ZERO; + java.math.BigDecimal waived = java.math.BigDecimal.ZERO; + java.math.BigDecimal writtenOff = java.math.BigDecimal.ZERO; + java.math.BigDecimal shareValue = java.math.BigDecimal.ZERO; + long borrowers = 0L; + for (final Map row : rows) { + charged = charged.add(toDecimal(row.get("interestCharged"))); + received = received.add(toDecimal(row.get("interestReceived"))); + receivable = receivable.add(toDecimal(row.get("interestReceivable"))); + waived = waived.add(toDecimal(row.get("interestWaived"))); + writtenOff = writtenOff.add(toDecimal(row.get("interestWrittenOff"))); + shareValue = shareValue.add(toDecimal(row.get("shareValue"))); + if (toLong(row.get("loanCount")) > 0L) { + borrowers++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("borrowingMembers", borrowers); + totals.put("interestCharged", charged); + totals.put("interestReceived", received); + totals.put("interestReceivable", receivable); + totals.put("interestWaived", waived); + totals.put("interestWrittenOff", writtenOff); + totals.put("shareValue", shareValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /enquiry — one member's whole share position, on one screen + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Enquiry: everything about one member's share position in a single answer - their + * accounts, every movement, the dividends declared to them, and the savings account their money + * moves through. + * + *

+ * Assembled server-side rather than by the client calling four endpoints, so what the counter + * reads out to a member standing in front of them is one consistent picture taken at one moment, + * not four responses that can disagree if a posting lands between them. + */ + @GET + @Path("/enquiry") + @Operation(summary = "One member's complete share position", + description = "Share accounts, every settled movement, dividends and linked savings for a single member, found by name, membership number or share account number.") + public Response enquiry(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.enquiry.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.isEmpty()) { + final Map empty = new LinkedHashMap<>(); + empty.put("matches", matches); + empty.put("member", null); + return Response.ok(gson.toJson(empty)).build(); + } + // More than one member answers the search — the caller picks, rather than this guessing + // and showing somebody else's shareholding to whoever is at the counter. + if (matches.size() > 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("member", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> members = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + to_char(c.activation_date, 'YYYY-MM-DD') AS "memberSince" + FROM m_client c + LEFT JOIN m_office o ON o.id = c.office_id + WHERE c.id = ? + """, resolvedClientId); + if (members.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.enquiry.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> accounts = this.jdbcTemplate.queryForList((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate", + sav.account_no AS "linkedSavingsAccountNo", + sav.account_balance_derived AS "linkedSavingsBalance" + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY sa.account_no + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + final List> movements = this.jdbcTemplate.queryForList(""" + SELECT t.id AS "transactionId", + sa.account_no AS "accountNo", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + t.status_enum AS "statusEnum", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE sa.client_id = ? AND t.is_active = true AND t.type_enum IN (%d, %d) + ORDER BY t.transaction_date DESC, t.id DESC + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + final List> dividends = this.jdbcTemplate.queryForList(""" + SELECT dd.id AS "dividendDetailId", + sa.account_no AS "accountNo", + sp.name AS "productName", + po.id AS "dividendId", + to_char(po.dividend_period_start_date, 'YYYY-MM-DD') AS "periodStart", + to_char(po.dividend_period_end_date, 'YYYY-MM-DD') AS "periodEnd", + po.rate_percentage AS "ratePercentage", + dd.amount AS "amount", + dd.status AS "statusEnum", + dd.savings_transaction_id AS "savingsTransactionId" + FROM m_share_account_dividend_details dd + JOIN m_share_account sa ON sa.id = dd.account_id + JOIN m_share_product_dividend_pay_out po ON po.id = dd.dividend_pay_out_id + LEFT JOIN m_share_product sp ON sp.id = po.product_id + WHERE sa.client_id = ? + ORDER BY po.dividend_period_end_date DESC, dd.id DESC + """, resolvedClientId); + + long sharesHeld = 0L; + java.math.BigDecimal shareValue = java.math.BigDecimal.ZERO; + for (final Map account : accounts) { + sharesHeld += toLong(account.get("sharesHeld")); + shareValue = shareValue.add(toDecimal(account.get("shareValue"))); + } + java.math.BigDecimal dividendTotal = java.math.BigDecimal.ZERO; + for (final Map dividend : dividends) { + dividendTotal = dividendTotal.add(toDecimal(dividend.get("amount"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("member", members.get(0)); + out.put("accounts", accounts); + out.put("movements", movements); + out.put("dividends", dividends); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", accounts.size()); + totals.put("sharesHeld", sharesHeld); + totals.put("shareValue", shareValue); + totals.put("movementCount", movements.size()); + totals.put("dividendTotal", dividendTotal); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /payment-certificate/{shareAccountId} — proof the shares were paid for + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Payment Certificate: what a member actually paid for their shares, and against which + * receipt. + * + *

+ * Distinct from a share certificate, which certifies ownership. This certifies payment — the + * document a member is asked for when the money, not the holding, is what has to be proved, and + * it therefore leads with amounts, dates and the voucher each one was received on. + * + *

+ * Every tranche is listed rather than a single total: a member who paid in instalments paid on + * several dates against several receipts, and a certificate showing one figure cannot be + * reconciled against any of them. + */ + @GET + @Path("/payment-certificate/{shareAccountId}") + @Operation(summary = "Share payment certificate", + description = "Account and member header, every payment made for shares with its receipt, and the entry fee, for printing as a certificate.") + public Response paymentCertificate(@PathParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + + final List> headers = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + sp.unit_price AS "unitPrice", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + COALESCE(sa.total_approved_shares, 0) AS "sharesHeld" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, shareAccountId); + if (headers.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.account.not.found", + "Share account " + shareAccountId + " was not found."); + } + + // Each purchase with the receipt the money arrived on. A cash tranche names its advance + // collection; a savings-funded one names no voucher because none was raised — the money came + // off the member's own account, which the funding column says instead of leaving a blank + // that reads like a missing record. + final List> payments = this.jdbcTemplate.queryForList(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "paymentDate", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.funding_source AS "fundingSource", + t.resolution_number AS "resolutionNumber", + t.status_enum AS "statusEnum", + col.receipt_no AS "receiptNo", + col.voucher_no AS "voucherNo", + to_char(col.collection_date, 'YYYY-MM-DD') AS "receiptDate", + col.collected_by_name AS "collectedByName", + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + LEFT JOIN m_share_advance_collection col ON col.id = t.advance_collection_id + WHERE t.account_id = ? AND t.is_active = true AND t.type_enum = %d AND t.status_enum = %d + ORDER BY t.transaction_date, t.id + """.formatted(TYPE_PURCHASED, TXN_APPROVED), shareAccountId); + + final List> charges = this.jdbcTemplate.queryForList(""" + SELECT ch.name AS "chargeName", + sac.amount AS "amount", + COALESCE(sac.amount_paid_derived, 0) AS "amountPaid", + COALESCE(sac.amount_outstanding_derived, 0) AS "amountOutstanding", + sac.is_paid_derived AS "paid" + FROM m_share_account_charge sac + JOIN m_charge ch ON ch.id = sac.charge_id + WHERE sac.account_id = ? AND sac.is_active = true + ORDER BY ch.name + """, shareAccountId); + + long shares = 0L; + java.math.BigDecimal paidForShares = java.math.BigDecimal.ZERO; + java.math.BigDecimal paidCharges = java.math.BigDecimal.ZERO; + for (final Map payment : payments) { + shares += toLong(payment.get("shares")); + paidForShares = paidForShares.add(toDecimal(payment.get("amount"))); + paidCharges = paidCharges.add(toDecimal(payment.get("chargeAmount"))); + } + for (final Map charge : charges) { + paidCharges = paidCharges.add(toDecimal(charge.get("amountPaid"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("account", headers.get(0)); + out.put("payments", payments); + out.put("charges", charges); + final Map totals = new LinkedHashMap<>(); + totals.put("sharesPaidFor", shares); + totals.put("paidForShares", paidForShares); + totals.put("paidCharges", paidCharges); + totals.put("totalPaid", paidForShares.add(paidCharges)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-detail — the full register, every column + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Detail: the complete share register — every account with its member, its class and the + * terms it was opened under, in one table. + * + *

+ * Where the Share Holder list answers "who holds shares", this answers "what exactly does this + * account say" — lock-in, minimum active period, linked savings, charges, tranche count and the + * governing resolution, which are otherwise spread across the account's own tabs one at a time. + */ + @GET + @Path("/share-detail") + @Operation(summary = "Full share account register", + description = "Every share account with member, class, terms, lock-in, linked savings, charges and tranche count.") + public Response shareDetail(@QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId, + @QueryParam("status") final Integer status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.external_id AS "externalId", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sp.short_name AS "productShortName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + COALESCE(bal.tranche_count, 0) AS "trancheCount", + sa.resolution_number AS "resolutionNumber", + to_char(sa.resolution_date, 'YYYY-MM-DD') AS "resolutionDate", + sa.lockin_period_frequency AS "lockinPeriod", + sa.lockin_period_frequency_enum AS "lockinPeriodEnum", + sa.minimum_active_period_frequency AS "minimumActivePeriod", + sa.minimum_active_period_frequency_enum AS "minimumActivePeriodEnum", + sa.allow_dividends_inactive_clients AS "allowDividendsInactive", + sav.account_no AS "linkedSavingsAccountNo", + COALESCE(chg.charge_total, 0) AS "chargeTotal", + COALESCE(chg.charge_outstanding, 0) AS "chargeOutstanding", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held, + COUNT(*) FILTER (WHERE t.type_enum = %d) AS tranche_count + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + LEFT JOIN ( + SELECT sac.account_id, + SUM(COALESCE(sac.amount, 0)) AS charge_total, + SUM(COALESCE(sac.amount_outstanding_derived, 0)) AS charge_outstanding + FROM m_share_account_charge sac + WHERE sac.is_active = true + GROUP BY sac.account_id + ) chg ON chg.account_id = sa.id + WHERE 1 = 1 + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (status != null) { + sql.append(" AND sa.status_enum = ? "); + args.add(status); + } + sql.append(" ORDER BY c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + java.math.BigDecimal chargesOutstanding = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + chargesOutstanding = chargesOutstanding.add(toDecimal(row.get("chargeOutstanding"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", rows.size()); + totals.put("sharesHeld", shares); + totals.put("shareValue", value); + totals.put("chargeOutstanding", chargesOutstanding); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /dividend-statement — what a member has been declared, year by year + // ══════════════════════════════════════════════════════════════════════ + /** + * Dividend Statement: the dividends declared to one member across every year, with what has been + * paid and what is still to come. + * + *

+ * Read to a member at the counter and handed over as their record, so it is scoped to one member + * and always shows the period, the rate and the status side by side: "declared" and "paid" are + * different answers to "have I had my dividend", and a statement that shows only an amount + * cannot distinguish them. + */ + @GET + @Path("/dividend-statement") + @Operation(summary = "One member's dividend statement", + description = "Every dividend declared to a member, by period and share class, with rate, amount and payment status.") + public Response dividendStatement(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.dividend.statement.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.size() != 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("member", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> members = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", c.account_no AS "cifNumber", c.display_name AS "clientName", + o.name AS "officeName" + FROM m_client c LEFT JOIN m_office o ON o.id = c.office_id WHERE c.id = ? + """, resolvedClientId); + if (members.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.dividend.statement.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT dd.id AS "dividendDetailId", + po.id AS "dividendId", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + to_char(po.dividend_period_start_date, 'YYYY-MM-DD') AS "periodStart", + to_char(po.dividend_period_end_date, 'YYYY-MM-DD') AS "periodEnd", + po.rate_percentage AS "ratePercentage", + dd.amount AS "amount", + dd.status AS "statusEnum", + dd.savings_transaction_id AS "savingsTransactionId", + COALESCE(bal.shares_held, 0) AS "sharesHeldNow" + FROM m_share_account_dividend_details dd + JOIN m_share_account sa ON sa.id = dd.account_id + JOIN m_share_product_dividend_pay_out po ON po.id = dd.dividend_pay_out_id + LEFT JOIN m_share_product sp ON sp.id = po.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY po.dividend_period_end_date DESC, dd.id DESC + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + java.math.BigDecimal declared = java.math.BigDecimal.ZERO; + java.math.BigDecimal paid = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + final java.math.BigDecimal amount = toDecimal(row.get("amount")); + declared = declared.add(amount); + // 600 is the posted/paid state on a dividend detail; anything earlier is still owed. + if (toLong(row.get("statusEnum")) == 600L) { + paid = paid.add(amount); + } + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("member", members.get(0)); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("declarationCount", rows.size()); + totals.put("declared", declared); + totals.put("paid", paid); + totals.put("outstanding", declared.subtract(paid)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /folio-statement — the member's folio in the share register + // ══════════════════════════════════════════════════════════════════════ + /** + * Folio Statement: one member's folio in the share register — every share account they hold and + * every movement across all of them, in one running sequence. + * + *

+ * Where the Account Statement is one account, a folio is the member: someone holding two share + * classes has two accounts but one folio, and the register is kept by folio. The running balance + * is therefore across accounts, which is the figure the register itself carries. + */ + @GET + @Path("/folio-statement") + @Operation(summary = "One member's folio statement", + description = "Every share account a member holds and every movement across all of them, with the folio's running holding.") + public Response folioStatement(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search, + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.folio.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.size() != 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("folio", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> folios = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", + c.account_no AS "folioNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + to_char(c.activation_date, 'YYYY-MM-DD') AS "memberSince" + FROM m_client c LEFT JOIN m_office o ON o.id = c.office_id WHERE c.id = ? + """, resolvedClientId); + if (folios.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.folio.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> accounts = this.jdbcTemplate.queryForList((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY sa.account_no + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + // Running balance across every account the folio holds, then filtered to the period — so a + // mid-year folio statement opens at the holding the member actually had, exactly as the + // single-account statement does. + final StringBuilder lineSql = new StringBuilder(""" + SELECT * FROM ( + SELECT t.id AS "transactionId", + sa.account_no AS "accountNo", + sp.name AS "productName", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesIn", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesOut", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) + WHEN t.type_enum = %d THEN -COALESCE(t.total_shares, 0) + ELSE 0 END) + OVER (ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "folioBalance", + t.transaction_date AS raw_date + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.client_id = ? AND t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR sa.collection_applied = true) + ) s WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, + TYPE_REDEEMED)); + final List lineArgs = new ArrayList<>(); + lineArgs.add(resolvedClientId); + if (fromDate != null && !fromDate.isBlank()) { + lineSql.append(" AND s.raw_date >= CAST(? AS date) "); + lineArgs.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + lineSql.append(" AND s.raw_date <= CAST(? AS date) "); + lineArgs.add(toDate); + } + lineSql.append(" ORDER BY raw_date, \"transactionId\" "); + + final List> lines = this.jdbcTemplate.queryForList(lineSql.toString(), lineArgs.toArray()); + for (final Map line : lines) { + line.remove("raw_date"); + } + + long held = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + for (final Map account : accounts) { + held += toLong(account.get("sharesHeld")); + value = value.add(toDecimal(account.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("folio", folios.get(0)); + out.put("accounts", accounts); + out.put("lines", lines); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", accounts.size()); + totals.put("sharesHeld", held); + totals.put("shareValue", value); + totals.put("movementCount", lines.size()); + totals.put("closingFolioBalance", lines.isEmpty() ? held : toLong(lines.get(lines.size() - 1).get("folioBalance"))); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /distinctive-numbers — which numbered shares each member actually holds + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Distinctive Number Detail: the numbered range of shares each allotment covers, per + * member and per share class. + * + *

+ * A share certificate names the distinctive numbers it covers — "shares 501 to 550" — and the + * register has to be able to say which member holds which numbers. There is no distinctive-number + * column anywhere in the schema, so rather than inventing one and back-filling a guess, the + * range is derived the way the society itself would have allotted them: shares are numbered in + * the order they were issued, per share class, so each paid-up allotment takes the next block. + * + *

+ * That derivation is stable for a given history — the same allotments always produce the same + * ranges — and self-evidently correct as long as the society has never renumbered. It is + * presented as derived rather than recorded, so nobody reads it as an independently-kept number. + * + *

+ * Redemptions are deliberately not renumbered: shares that come back leave a gap in the sequence + * rather than shuffling everybody else's certificates, which is exactly what a real register + * does. + */ + @GET + @Path("/distinctive-numbers") + @Operation(summary = "Share distinctive number register", + description = "The numbered share range each allotment covers, per member and share class, derived from issue order.") + public Response distinctiveNumbers(@QueryParam("productId") final Long productId, @QueryParam("clientId") final Long clientId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "allotmentDate", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + -- Numbered in issue order within the share class: this allotment starts one + -- after everything issued before it, and runs for its own share count. The + -- very first allotment in a class has nothing before it, and the window sums + -- to NULL there rather than zero — coalesced, or share number one would come + -- out blank on exactly the row a reader checks first. + COALESCE(SUM(COALESCE(t.total_shares, 0)) OVER ( + PARTITION BY sa.product_id + ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) + 1 AS "distinctiveFrom", + SUM(COALESCE(t.total_shares, 0)) OVER ( + PARTITION BY sa.product_id + ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS "distinctiveTo" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND (t.funding_source IS NOT NULL OR sa.collection_applied = true) + """.formatted(TXN_APPROVED, TYPE_PURCHASED)); + + final List args = new ArrayList<>(); + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (clientId != null) { + sql.append(" AND sa.client_id = ? "); + args.add(clientId); + } + sql.append(" ORDER BY sp.name, t.transaction_date, t.id "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long issued = 0L; + for (final Map row : rows) { + issued += toLong(row.get("shares")); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("allotmentCount", rows.size()); + totals.put("sharesNumbered", issued); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /area-wise — where the society's members and its capital sit + // ══════════════════════════════════════════════════════════════════════ + /** + * Area Wise: members and share capital grouped by where the members live. + * + *

+ * A co-operative's area of operation is part of its licence, so "how much of our capital comes + * from which area" is a question the board and the Registrar both ask. Grouped on the member's + * own recorded address rather than their branch, since the two answer different questions and it + * is the address a licence is written against. + * + *

+ * Members with no address on file are their own group rather than being dropped — an + * unaddressable member is a gap in the register worth seeing, not a row to hide. + */ + @GET + @Path("/area-wise") + @Operation(summary = "Members and share capital by area", + description = "Shareholding grouped by the member's recorded city or area, with the members behind each group.") + public Response areaWise(@QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder holdings = new StringBuilder(""" + SELECT c.id AS client_id, + c.account_no AS cif_number, + c.display_name AS client_name, + c.mobile_no AS mobile_no, + o.name AS office_name, + COALESCE(NULLIF(TRIM(addr.city), ''), + NULLIF(TRIM(addr.town_village), ''), + NULLIF(TRIM(addr.county_district), ''), + 'Not recorded') AS area, + addr.postal_code AS postal_code, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT a.client_id, + MIN(a.city) AS city, + MIN(a.town_village) AS town_village, + MIN(a.county_district) AS county_district, + MIN(a.postal_code) AS postal_code + FROM m_address a + WHERE COALESCE(a.is_active, true) = true AND COALESCE(a.is_deleted, false) = false + GROUP BY a.client_id + ) addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (productId != null) { + holdings.append(" AND sp.id = ? "); + args.add(productId); + } + holdings.append(""" + GROUP BY c.id, c.account_no, c.display_name, c.mobile_no, o.name, addr.city, addr.town_village, + addr.county_district, addr.postal_code + HAVING SUM(COALESCE(bal.shares_held, 0)) > 0 + """); + + final List> areas = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT area AS "area", + COUNT(*) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY area + ORDER BY SUM(share_value) DESC, area + """, args.toArray()); + + final List> members = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT client_id AS "clientId", + cif_number AS "cifNumber", + client_name AS "clientName", + mobile_no AS "mobileNo", + office_name AS "officeName", + area AS "area", + postal_code AS "postalCode", + shares_held AS "sharesHeld", + share_value AS "shareValue" + FROM held + ORDER BY area, client_name + """, args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + for (final Map row : members) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("areas", areas); + out.put("members", members); + final Map totals = new LinkedHashMap<>(); + totals.put("areaCount", areas.size()); + totals.put("memberCount", members.size()); + totals.put("shares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /mailing-list — what a label and a letter both need + // ══════════════════════════════════════════════════════════════════════ + /** + * The address list behind Share Label and Share Letter: every shareholding member with the + * address to reach them at and the holding to quote. + * + *

+ * One endpoint for both, because a label and a letter want exactly the same rows — name, + * address, folio, holding — and differ only in what is printed around them. Splitting it would + * mean two queries that have to be kept saying the same thing. + * + *

+ * Members with no usable address are returned too, flagged: a mailing run has to know who it + * cannot reach, and silently shortening the list is how those members never hear from the + * society again. + */ + @GET + @Path("/mailing-list") + @Operation(summary = "Shareholding members with addresses", + description = "Name, address, folio and holding for every shareholding member — the rows behind share labels and letters.") + public Response mailingList(@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("area") final String area) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT c.id AS "clientId", + c.account_no AS "folioNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + addr.address_line_1 AS "addressLine1", + addr.address_line_2 AS "addressLine2", + addr.street AS "street", + COALESCE(NULLIF(TRIM(addr.city), ''), NULLIF(TRIM(addr.town_village), '')) AS "city", + addr.county_district AS "district", + addr.postal_code AS "postalCode", + COALESCE(NULLIF(TRIM(addr.city), ''), + NULLIF(TRIM(addr.town_village), ''), + NULLIF(TRIM(addr.county_district), ''), + 'Not recorded') AS "area", + SUM(COALESCE(bal.shares_held, 0)) AS "sharesHeld", + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS "shareValue", + STRING_AGG(sa.account_no, ', ' ORDER BY sa.account_no) AS "shareAccountNos", + (addr.address_line_1 IS NOT NULL OR addr.street IS NOT NULL + OR addr.city IS NOT NULL OR addr.town_village IS NOT NULL) AS "addressable" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT a.client_id, + MIN(a.address_line_1) AS address_line_1, + MIN(a.address_line_2) AS address_line_2, + MIN(a.street) AS street, + MIN(a.city) AS city, + MIN(a.town_village) AS town_village, + MIN(a.county_district) AS county_district, + MIN(a.postal_code) AS postal_code + FROM m_address a + WHERE COALESCE(a.is_active, true) = true AND COALESCE(a.is_deleted, false) = false + GROUP BY a.client_id + ) addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + sql.append(""" + GROUP BY c.id, c.account_no, c.display_name, c.mobile_no, o.name, addr.address_line_1, + addr.address_line_2, addr.street, addr.city, addr.town_village, addr.county_district, + addr.postal_code + HAVING SUM(COALESCE(bal.shares_held, 0)) > 0 + """); + if (area != null && !area.isBlank()) { + // Applied after grouping because the area is itself a coalesced expression. + sql.append(" AND COALESCE(NULLIF(TRIM(addr.city), ''), NULLIF(TRIM(addr.town_village), '')," + + " NULLIF(TRIM(addr.county_district), ''), 'Not recorded') = ? "); + args.add(area); + } + sql.append(" ORDER BY c.display_name "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + int unaddressable = 0; + for (final Map row : rows) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + if (!Boolean.TRUE.equals(row.get("addressable"))) { + unaddressable++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("unaddressable", unaddressable); + totals.put("shares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** Period predicate applied outside the window function, so the running balance opens correctly. */ + private String periodFilter(final String fromDate, final String toDate) { + final StringBuilder filter = new StringBuilder(); + if (fromDate != null && !fromDate.isBlank()) { + filter.append(" AND s.raw_date >= CAST(? AS date) "); + } + if (toDate != null && !toDate.isBlank()) { + filter.append(" AND s.raw_date <= CAST(? AS date) "); + } + return filter.toString(); + } + + private Object[] periodArgs(final Long shareAccountId, final String fromDate, final String toDate) { + final List args = new ArrayList<>(); + args.add(shareAccountId); + if (fromDate != null && !fromDate.isBlank()) { + args.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + args.add(toDate); + } + return args.toArray(); + } + + private long toLong(final Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + /** + * How many rows sit in each holding state, and how many shares are allotted but unpaid. + * + *

+ * Reported alongside the money so a reader is told "two of these are closed and one was never + * collected" up front, rather than being left to work out from a column of zeroes why the list is + * longer than the capital suggests. Every state is present even at zero, so a report that has no + * closed accounts still says so instead of going quiet about it. + */ + private Map holdingStateCounts(final List> rows) { + final Map counts = new LinkedHashMap<>(); + for (final String state : List.of("HOLDING", "UNPAID", "NIL", "CLOSED", "NOT_ACTIVE", "REJECTED")) { + counts.put(state, 0L); + } + long unpaidShares = 0L; + for (final Map row : rows) { + final Object state = row.get("holdingState"); + if (state != null) { + counts.put(String.valueOf(state), toLong(counts.get(String.valueOf(state))) + 1L); + } + unpaidShares += toLong(row.get("unpaidShares")); + } + counts.put("unpaidShares", unpaidShares); + return counts; + } + + private java.math.BigDecimal toDecimal(final Object value) { + if (value instanceof java.math.BigDecimal decimal) { + return decimal; + } + return value instanceof Number number ? java.math.BigDecimal.valueOf(number.doubleValue()) : java.math.BigDecimal.ZERO; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java new file mode 100644 index 000000000..b3f69b206 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java @@ -0,0 +1,496 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Operation; +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 jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Share transfer between two members. + * + *

+ * A transfer is not a sale to the society and a purchase from it — no money reaches the society and + * its share capital does not change, so no GL entry is posted. What changes is the register: one + * member's holding falls, another's rises, by the same number on the same day at the same price. + * That is recorded as the two share transactions the rest of the module already understands + * (a redemption on the transferor, a purchase on the transferee), tied together by the transfer row + * so a later reader can tell it was one act rather than two unrelated dealings. + * + *

+ * Maker-checker throughout: a transfer moves ownership and with it voting rights, which a + * co-operative does not leave to one clerk. Nothing moves until a checker approves, and every rule + * is re-checked at approval rather than trusted from submission — holdings move while a request + * waits, and a transfer that was valid on Monday can be impossible by Wednesday. + */ +@Path("/v1/sharetransfers") +@Component +@Tag(name = "Share Transfers", description = "Transfer of shares from one member to another, under board resolution.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareTransferApiResource { + + private static final int TYPE_PURCHASED = 500; + private static final int TYPE_REDEEMED = 600; + private static final int TXN_APPROVED = 300; + private static final int ACCOUNT_ACTIVE = 300; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ══════════════════════════════════════════════════════════════════════ + // POST — a clerk records a transfer for approval + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + @Operation(summary = "Submit a share transfer for approval", + description = "Records a transfer of shares between two members. Nothing moves until a checker approves it.") + public Response submit(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject(); + + final Long fromAccountId = getLong(json, "fromShareAccountId"); + final Long toAccountId = getLong(json, "toShareAccountId"); + final Long shares = getLong(json, "shares"); + final String transferDate = getString(json, "transferDate"); + final String resolutionNumber = getString(json, "resolutionNumber"); + final String meetingDate = getString(json, "meetingDate"); + final String reason = getString(json, "reason"); + + if (fromAccountId == null || toAccountId == null) { + throw badRequest("error.msg.sharetransfer.accounts.required", "Both the transferring and receiving share accounts are required."); + } + if (shares == null || shares <= 0) { + throw badRequest("error.msg.sharetransfer.shares.invalid", "The number of shares to transfer must be greater than zero."); + } + // A co-operative transfers shares under a board resolution, so the resolution is part of the + // request rather than something captured afterwards — a transfer with no resolution behind it + // is the finding an auditor writes up. + if (resolutionNumber == null || resolutionNumber.isBlank()) { + throw badRequest("error.msg.sharetransfer.resolution.required", + "The board resolution authorising this transfer is required."); + } + if (meetingDate == null || meetingDate.isBlank()) { + throw badRequest("error.msg.sharetransfer.meeting.date.required", "The board meeting date is required."); + } + + final LocalDate effectiveDate = transferDate == null || transferDate.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(transferDate); + + final Map from = loadAccount(fromAccountId, "transferring"); + final Map to = loadAccount(toAccountId, "receiving"); + validateTransfer(from, to, shares); + + final BigDecimal unitPrice = toDecimal(from.get("unit_price")); + final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares)); + final String reference = nextReference(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + + final Long id = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_transfer + (transfer_reference, from_share_account_id, to_share_account_id, shares, unit_price, transfer_amount, + transfer_date, resolution_number, meeting_date, reason, status, maker_id, maker_name, submitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,'PENDING',?,?,?) + RETURNING id + """, Long.class, reference, fromAccountId, toAccountId, shares, unitPrice, amount, + java.sql.Date.valueOf(effectiveDate), resolutionNumber, java.sql.Date.valueOf(LocalDate.parse(meetingDate)), reason, + currentUser.getId(), currentUser.getDisplayName(), java.sql.Timestamp.valueOf(now)); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("transferReference", reference); + out.put("status", "PENDING"); + out.put("shares", shares); + out.put("amount", amount); + out.put("message", "Transfer " + reference + " recorded and sent for approval. Nothing moves until it is approved."); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — the transfer register + // ══════════════════════════════════════════════════════════════════════ + @GET + @Operation(summary = "Share transfer register", + description = "Every share transfer with both members, the shares moved and the resolution behind it.") + public Response list(@QueryParam("status") final String status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT st.id AS "transferId", + st.transfer_reference AS "transferReference", + to_char(st.transfer_date, 'YYYY-MM-DD') AS "transferDate", + st.shares AS "shares", + st.unit_price AS "unitPrice", + st.transfer_amount AS "transferAmount", + st.status AS "status", + st.resolution_number AS "resolutionNumber", + to_char(st.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + st.reason AS "reason", + st.maker_name AS "makerName", + to_char(st.submitted_at, 'YYYY-MM-DD') AS "submittedOn", + st.checker_name AS "checkerName", + to_char(st.actioned_at, 'YYYY-MM-DD') AS "actionedOn", + st.rejection_reason AS "rejectionReason", + fa.id AS "fromShareAccountId", + fa.account_no AS "fromAccountNo", + fc.id AS "fromClientId", + fc.display_name AS "fromClientName", + fc.account_no AS "fromCifNumber", + ta.id AS "toShareAccountId", + ta.account_no AS "toAccountNo", + tc.id AS "toClientId", + tc.display_name AS "toClientName", + tc.account_no AS "toCifNumber", + sp.name AS "productName", + fa.currency_code AS "currencyCode" + FROM m_share_transfer st + JOIN m_share_account fa ON fa.id = st.from_share_account_id + JOIN m_client fc ON fc.id = fa.client_id + JOIN m_share_account ta ON ta.id = st.to_share_account_id + JOIN m_client tc ON tc.id = ta.client_id + LEFT JOIN m_share_product sp ON sp.id = fa.product_id + WHERE 1 = 1 + """); + final List args = new ArrayList<>(); + if (status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status)) { + sql.append(" AND st.status = ? "); + args.add(status); + } + sql.append(" ORDER BY st.submitted_at DESC, st.id DESC "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long transferredShares = 0L; + BigDecimal transferredValue = BigDecimal.ZERO; + int pending = 0; + for (final Map row : rows) { + if ("APPROVED".equals(row.get("status"))) { + transferredShares += toLong(row.get("shares")); + transferredValue = transferredValue.add(toDecimal(row.get("transferAmount"))); + } else if ("PENDING".equals(row.get("status"))) { + pending++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("transferCount", rows.size()); + totals.put("pendingCount", pending); + totals.put("sharesTransferred", transferredShares); + totals.put("valueTransferred", transferredValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /{id}/approve — the transfer actually happens here + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{transferId}/approve") + @Transactional + @Operation(summary = "Approve a share transfer", + description = "Re-checks every rule against today's holdings, then moves the shares: a redemption on the transferor and a purchase on the transferee.") + public Response approve(@PathParam("transferId") final Long transferId) { + final AppUser currentUser = this.context.authenticatedUser(); + + final Map transfer = loadTransfer(transferId); + if (!"PENDING".equals(transfer.get("status"))) { + throw badRequest("error.msg.sharetransfer.not.pending", + "Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + "."); + } + + final Long fromAccountId = toLong(transfer.get("from_share_account_id")); + final Long toAccountId = toLong(transfer.get("to_share_account_id")); + final long shares = toLong(transfer.get("shares")); + + // Re-checked, not trusted: holdings move while a request waits, and a transfer that was + // valid when it was raised can be impossible by the time it reaches a checker. + final Map from = loadAccount(fromAccountId, "transferring"); + final Map to = loadAccount(toAccountId, "receiving"); + validateTransfer(from, to, shares); + + final BigDecimal unitPrice = toDecimal(transfer.get("unit_price")); + final BigDecimal amount = toDecimal(transfer.get("transfer_amount")); + final java.sql.Date effectiveDate = (java.sql.Date) transfer.get("transfer_date"); + final Object meetingDate = transfer.get("meeting_date"); + final String resolutionNumber = (String) transfer.get("resolution_number"); + + // The transferor is redeemed and the transferee purchases — the same two movements the rest + // of the share module already reads, so every balance, statement and report picks this up + // without knowing transfers exist. + final Long fromTxnId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_account_transactions + (account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active, + resolution_number, meeting_date) + VALUES (?,?,?,?,?,?,?,true,?,?) + RETURNING id + """, Long.class, fromAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_REDEEMED, + resolutionNumber, meetingDate); + + final Long toTxnId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_account_transactions + (account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active, + resolution_number, meeting_date) + VALUES (?,?,?,?,?,?,?,true,?,?) + RETURNING id + """, Long.class, toAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_PURCHASED, + resolutionNumber, meetingDate); + + // The account's own running total is kept in step with the transactions just written, since + // the rest of the platform reads it directly. + this.jdbcTemplate.update( + "UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) - ? WHERE id = ?", shares, + fromAccountId); + this.jdbcTemplate.update( + "UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) + ? WHERE id = ?", shares, + toAccountId); + + this.jdbcTemplate.update(""" + UPDATE m_share_transfer SET status = 'APPROVED', from_transaction_id = ?, to_transaction_id = ?, + checker_id = ?, checker_name = ?, actioned_at = ? + WHERE id = ? + """, fromTxnId, toTxnId, currentUser.getId(), currentUser.getDisplayName(), + java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", transferId); + out.put("transferReference", transfer.get("transfer_reference")); + out.put("status", "APPROVED"); + out.put("shares", shares); + out.put("amount", amount); + out.put("fromTransactionId", fromTxnId); + out.put("toTransactionId", toTxnId); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /{id}/reject + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{transferId}/reject") + @Transactional + @Operation(summary = "Reject a share transfer") + public Response reject(@PathParam("transferId") final Long transferId, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject(); + + final Map transfer = loadTransfer(transferId); + if (!"PENDING".equals(transfer.get("status"))) { + throw badRequest("error.msg.sharetransfer.not.pending", + "Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + "."); + } + + this.jdbcTemplate.update(""" + UPDATE m_share_transfer SET status = 'REJECTED', rejection_reason = ?, checker_id = ?, checker_name = ?, + actioned_at = ? + WHERE id = ? + """, getString(json, "rejectionReason"), currentUser.getId(), currentUser.getDisplayName(), + java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", transferId); + out.put("status", "REJECTED"); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** + * Every rule a transfer has to satisfy, applied identically at submission and at approval. + * + *

+ * Holdings are derived from the transactions rather than read off the account's running total, + * for the same reason the balance report does it: the total is what the account currently says, + * and this needs what the movements actually add up to. + */ + private void validateTransfer(final Map from, final Map to, final long shares) { + final Long fromAccountId = toLong(from.get("id")); + final Long toAccountId = toLong(to.get("id")); + + if (fromAccountId.equals(toAccountId)) { + throw badRequest("error.msg.sharetransfer.same.account", "A share account cannot transfer to itself."); + } + if (toLong(from.get("client_id")) == toLong(to.get("client_id"))) { + throw badRequest("error.msg.sharetransfer.same.member", + "Both accounts belong to the same member, so there is nothing to transfer between."); + } + // Different share classes carry different rights and prices; moving shares across them would + // be an issue and a redemption, not a transfer. + if (toLong(from.get("product_id")) != toLong(to.get("product_id"))) { + throw badRequest("error.msg.sharetransfer.product.mismatch", + "Shares can only be transferred within the same share class. " + from.get("product_name") + " and " + + to.get("product_name") + " are different classes."); + } + if (toInt(from.get("status_enum")) != ACCOUNT_ACTIVE) { + throw badRequest("error.msg.sharetransfer.from.not.active", + "Share account " + from.get("account_no") + " is not active, so it cannot transfer shares."); + } + if (toInt(to.get("status_enum")) != ACCOUNT_ACTIVE) { + throw badRequest("error.msg.sharetransfer.to.not.active", + "Share account " + to.get("account_no") + " is not active, so it cannot receive shares."); + } + + final long fromHeld = heldShares(fromAccountId); + final long toHeld = heldShares(toAccountId); + if (fromHeld < shares) { + throw badRequest("error.msg.sharetransfer.insufficient.shares", "Share account " + from.get("account_no") + " holds " + + fromHeld + " shares, which is fewer than the " + shares + " being transferred."); + } + + final Long maximum = toLongOrNull(to.get("maximum_client_shares")); + if (maximum != null && toHeld + shares > maximum) { + throw badRequest("error.msg.sharetransfer.above.maximum", + "The transfer would leave " + to.get("to_client_name") + " holding " + (toHeld + shares) + + " shares, above the " + maximum + " this share class allows one member."); + } + + // A transferor may go to nil — that is a member leaving — but may not be left holding less + // than the class's minimum, which is a membership the society cannot recognise. + final Long minimum = toLongOrNull(from.get("minimum_client_shares")); + final long remaining = fromHeld - shares; + if (minimum != null && remaining > 0 && remaining < minimum) { + throw badRequest("error.msg.sharetransfer.below.minimum", + "The transfer would leave " + from.get("account_no") + " holding " + remaining + " shares, below the " + + minimum + " minimum this share class requires. Transfer all " + fromHeld + + " shares, or leave at least " + minimum + "."); + } + } + + /** + * What the account's settled movements actually add up to, ignoring its running total. + * + *

+ * Only paid-up shares can be transferred. An opening tranche whose collection has never been + * applied is shares allotted against money the society never received — the member does not yet + * own them to give away, and transferring them would move an unpaid holding onto somebody else's + * name. The account's own {@code total_approved_shares} counts them, which is exactly why this + * walks the transactions instead. + */ + private long heldShares(final Long shareAccountId) { + final Long held = this.jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END), 0) + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + AND (t.type_enum = ? OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """, Long.class, TYPE_PURCHASED, TYPE_REDEEMED, shareAccountId, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED, + TYPE_REDEEMED); + return held == null ? 0L : held; + } + + private Map loadAccount(final Long shareAccountId, final String side) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, + c.display_name AS to_client_name, c.account_no AS cif_number, + sp.name AS product_name, sp.unit_price, sp.minimum_client_shares, sp.maximum_client_shares + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, shareAccountId); + if (rows.isEmpty()) { + throw badRequest("error.msg.sharetransfer.account.not.found", + "The " + side + " share account " + shareAccountId + " was not found."); + } + return rows.get(0); + } + + private Map loadTransfer(final Long transferId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_share_transfer WHERE id = ?", transferId); + if (rows.isEmpty()) { + throw badRequest("error.msg.sharetransfer.not.found", "Transfer " + transferId + " was not found."); + } + return rows.get(0); + } + + /** TRF-YYMMDD-NNNN, dated and serial, so a reference says when it was raised. */ + private String nextReference() { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String prefix = "TRF-" + today.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + "-"; + final Integer used = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_transfer WHERE transfer_reference LIKE ?", Integer.class, prefix + "%"); + return prefix + String.format("%04d", (used == null ? 0 : used) + 1); + } + + private PlatformDataIntegrityException badRequest(final String code, final String message) { + return new PlatformDataIntegrityException(code, message); + } + + private Long getLong(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null; + } + + private String getString(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + + private long toLong(final Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + private Long toLongOrNull(final Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + private int toInt(final Object value) { + return value instanceof Number number ? number.intValue() : 0; + } + + private BigDecimal toDecimal(final Object value) { + if (value instanceof BigDecimal decimal) { + return decimal; + } + return value instanceof Number number ? BigDecimal.valueOf(number.doubleValue()) : BigDecimal.ZERO; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index d07a28c4e..c3f1fc7a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -748,6 +748,16 @@ public class ShareAccountDataSerializer { if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) { baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state"); } + // An Active account can still be an unpaid application -- activation posts no GL of its own, + // only Apply Collection does. A further tranche on top of a first allotment nobody has paid + // for yet would grow a holding that does not exist: settle the original application first, + // then add to it. Read straight from the column rather than through the entity, which does + // not map it -- collection_applied lives outside JPA, set only by the collection endpoints. + final Boolean collectionApplied = this.jdbcTemplate + .queryForObject("SELECT collection_applied FROM m_share_account WHERE id = ?", Boolean.class, account.getId()); + if (!Boolean.TRUE.equals(collectionApplied)) { + baseDataValidator.failWithCodeNoParameterAddedToErrorCode("original.application.not.yet.settled"); + } LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull(); final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 4c0994bd0..730b2fdd7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -112,4 +112,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml new file mode 100644 index 000000000..d5e130e85 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6046134b515281e7dc6a3bb80c84172e8bdd147a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:56:49 +0530 Subject: [PATCH 53/55] fix(shares): drop stale client-mismatch check, fix stale client_id on settle - Remove the client-mismatch validation in the Additional Shares tranche submit path (ShareAccountDataSerializer) for parity with the same decision already made on the first-application Apply Collection flow -- a voucher a counter was shown and allowed to pick should not then be rejected for the same reason at the very last step - Rewrite m_share_advance_collection.client_id to the settling account's own client at the moment a voucher is actually applied (both ShareAdvanceCollectionApiResource.apply() and AdditionalShareTrancheFundingService.settle()) -- a voucher can now be found and applied from any member's screen, so whoever last searched it is not necessarily who its money became share capital for, and the stale value was misnaming the receipt in its own detail popup after settlement Co-Authored-By: Claude Sonnet 5 --- .../api/ShareAdvanceCollectionApiResource.java | 15 +++++++++++---- .../serialization/ShareAccountDataSerializer.java | 12 +++++------- .../AdditionalShareTrancheFundingService.java | 10 ++++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index d2a8a5be9..4874057f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -712,17 +712,24 @@ public class ShareAdvanceCollectionApiResource { // Every receipt in this settlement is marked applied, not just the one in the path — they // were all consumed by it, and an unmarked one would still show as money awaiting use. The fee // receipt is marked applied too, even though it posted no GL entry of its own. + // + // client_id is rewritten to this account's own client here too -- a voucher can now be found + // and applied from any member's screen, so whoever last searched it (the value it carried + // going in) is not necessarily who its money actually became share capital for. Leaving the + // old value in place would make the receipt's own record misname whose it is the moment it + // settles, which is exactly the figure a receipt-detail popup reads back afterwards. + final Object settledClientId = acct.get("client_id"); for (final Long id : collectionIds) { jdbcTemplate.update( "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " - + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", - shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); + + " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, id); } if (chargeCollectionId != null) { jdbcTemplate.update( "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " - + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", - shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId); + + " applied_on_date = ?, applied_gl_transaction_id = ?, client_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, settledClientId, chargeCollectionId); } // Reveals the purchase transaction in the passbook and transactions views. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index c3f1fc7a8..29bbb45d2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -894,13 +894,11 @@ public class ShareAccountDataSerializer { // already used" does not say which one to go and look at. final Object label = collection.get("voucher_no") != null ? collection.get("voucher_no") : collection.get("receipt_no"); - final Number collectionClientId = (Number) collection.get("client_id"); - if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) { - baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId) - .failWithCodeAndMessage("share.advance.collection.client.mismatch", - "Voucher " + label + " belongs to a different member."); - } - + // No client-match check here, matching the same product decision already applied to the + // first-application Apply Collection flow: a voucher is reachable and attachable from any + // account's screen (see ShareAdvanceCollectionApiResource), so rejecting it again here, + // at the very last step, would only fail a submission the counter was told was valid. + // // Only money still sitting in Sundry can fund a tranche. APPLIED has already become // share capital somewhere, and REFUNDED went back to the member. final String status = (String) collection.get("status"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java index fcf1b502a..4598e3211 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -146,12 +146,18 @@ public class AdditionalShareTrancheFundingService { } // Every receipt behind the tranche, not just the lead one: a member's later instalments have // become share capital too, and one left UNAPPLIED could be spent a second time. + // + // client_id is rewritten to this account's own client here too -- a voucher can now be found + // and applied from any member's screen, so whoever last searched it (the value it carried + // going in) is not necessarily who its money actually became share capital for. Leaving the + // old value in place would make the receipt's own record misname whose it is the moment it + // settles, which is exactly the figure a receipt-detail popup reads back afterwards. for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) { this.jdbcTemplate.update(""" UPDATE m_share_advance_collection - SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ? + SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?, client_id = ? WHERE id = ? AND status = 'UNAPPLIED' - """, account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), collectionId); + """, account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), account.getClientId(), collectionId); } } From dd4bb4a1c08ece227990e9448a208a72c0d9c674 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 18:58:40 +0530 Subject: [PATCH 54/55] feat(shares, cashier): enforce Share Collection entry type on listing, single receipt fetch, product summary Only vouchers tagged with the "Share Collection" entry type reach a share account. The adoption path already checked this, but the listing did not, so an already-adopted "General" voucher kept reappearing at Apply Collection and Apply Additional Shares time. SHARE_COLLECTION_TAGGED_ONLY now filters the listing query itself, against both the cashier-deposit and clerk-disbursement request tables. /unattributed takes an optional clientId and answers with both lists in one response -- that member's own unapplied receipts under "own", every other available receipt under "rows", with the member's own excluded from the latter. The two endpoints read the same table under the same filters, so asking twice only ever fetched the same rows twice, and keeping the query in two places is what let the entry-type filter land on one and miss the other. Both now share unappliedFor(). The entry fee is never posted a second time. A receipt already sitting in an INCOME GL was recognised as income by the cashier's own entry when it was taken, so applying the collection now skips both legs for it and counts it only towards verifying the fee was collected. Adds GET /sharereports/product-summary: authorised, issued, available and unpaid shares per product, with the distinctive range and account count. Adds a series-overlap pre-check to sponsor DD inventory registration, which previously surfaced as a bare duplicate-key error. Adds migration 0568 for the columns the clerk share-withdrawal settlement path reads (settlement_mode, tok_no, cash_settlement_txn_id, cashier_id, disbursed_by, disbursed_at, disbursement_denomination_data), each changeset guarded so it is a no-op where the column already exists. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShareAdvanceCollectionApiResource.java | 131 +++++++++++++++--- .../api/ShareReportsApiResource.java | 84 +++++++++++ .../SponsorDDWritePlatformServiceImpl.java | 15 ++ .../tenant/final-changelog-tenant.xml | 1 + ...mode_to_clerk_share_withdrawal_request.xml | 119 ++++++++++++++++ 5 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 4874057f9..b3164ea4a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -97,12 +97,42 @@ public class ShareAdvanceCollectionApiResource { private static final int DEBIT = 2; private static final int CREDIT = 1; + // ── acc_gl_account.classification_enum ───────────────────────────────── + // Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE. + private static final int GL_CLASSIFICATION_INCOME = 4; + // ── CashAccountsForShares financial_account_type ─────────────────────── private static final int FA_SHARES_REFERENCE = 1; private static final int FA_SHARES_SUSPENSE = 2; private static final int FA_INCOME_FROM_FEES = 3; private static final int FA_SHARES_EQUITY = 4; + /** + * Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually + * collected FOR a share collection, and nothing else. + * + *

+ * The Entry Type the counter picks at save time is the only thing that says what a GL receipt was + * taken for, so it decides this too: a receipt left General, or tagged for some other purpose + * entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the + * adoption path was not enough -- a receipt adopted before that guard existed stayed on these + * lists for ever, which is exactly how a General voucher kept reappearing against share accounts. + * + *

+ * A collection with no voucher of its own is the one thing this lets through: those come from the + * direct Share Advance Collection endpoint, which exists for nothing but share collections, so + * there is no counter tag to read and none needed. + */ + private static final String SHARE_COLLECTION_TAGGED_ONLY = """ + AND (c.voucher_no IS NULL + OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d + JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id + WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection') + OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r + JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id + WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection')) + """; + // ── acc_product_mapping.product_type for shares ──────────────────────── private static final int PRODUCT_TYPE_SHARES = 4; @@ -198,6 +228,21 @@ public class ShareAdvanceCollectionApiResource { // needs the by-voucher search below, same as before. autoAdoptShareCollectionTagged(); + final List> rows = unappliedFor(clientId); + final Map out = new LinkedHashMap<>(); + out.put("unappliedCollections", rows); + out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Unapplied receipts ready to be ticked, for one member or for everybody. + * + * Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the + * same rows under {@code own} so an Apply screen needs a single call rather than two that were + * only ever querying the same table with the same filters. + */ + private List> unappliedFor(final Long clientId) { final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," + " cl.display_name AS client_name, " + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," @@ -207,17 +252,13 @@ public class ShareAdvanceCollectionApiResource { + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + " ORDER BY je.id LIMIT 1) AS gl_code " - + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "); + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' " + + SHARE_COLLECTION_TAGGED_ONLY); if (clientId != null) { sql.append(" AND c.client_id = ").append(clientId); } sql.append(" ORDER BY c.collection_date, c.id"); - - final List> rows = jdbcTemplate.queryForList(sql.toString()); - final Map out = new LinkedHashMap<>(); - out.put("unappliedCollections", rows); - out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); - return Response.ok(gson.toJson(out)).build(); + return jdbcTemplate.queryForList(sql.toString()); } // ══════════════════════════════════════════════════════════════════════ @@ -253,9 +294,17 @@ public class ShareAdvanceCollectionApiResource { * mismatch rather than silently moving the money -- this list only makes the voucher visible, it * does not reassign it. */ + /** + * @param clientId + * the member whose own Apply screen this is. Their own unapplied receipts come back + * separately under {@code own} -- the same rows, in the same shape, that + * {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and + * those rows are left out of {@code rows} so the same receipt is never listed twice on + * one screen. Omit it and everything available comes back under {@code rows}. + */ @GET @Path("/unattributed") - public Response listUnattributed() { + public Response listUnattributed(@QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); // Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a @@ -311,10 +360,15 @@ public class ShareAdvanceCollectionApiResource { + " WHERE c.status = 'UNAPPLIED'" + " AND ((cdr.id IS NULL AND clr.id IS NULL)" + " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')" + // This member's own receipts come back under "own" below, ready to tick; listing + // them here as well would put the same receipt on screen twice, once needing a + // tick and once an Attach, for no difference in what it is. + + (clientId != null ? " AND c.client_id <> " + clientId : "") + " ORDER BY \"txnDate\" DESC"); final Map out = new LinkedHashMap<>(); out.put("rows", rows); + out.put("own", clientId == null ? List.of() : unappliedFor(clientId)); return Response.ok(gson.toJson(out)).build(); } @@ -653,28 +707,47 @@ public class ShareAdvanceCollectionApiResource { // that had never received it, and left the account that had holding the balance for ever. // Debiting each receipt's own account is also what the clerk sees: the GL picked against the // voucher on the apply screen is the account being cleared here. + // + // A receipt that already sits in an INCOME-classified GL is left exactly where it is, not + // debited out here at all -- an entry fee the cashier deposited straight into its own income + // account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it) + // was already recognised as income the moment it was banked. Debiting it out and crediting a + // fresh income leg below for the same rupees would recognise it a second time -- the + // duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted. + // A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy + // single-receipt case where share value and fee arrived together) still needs clearing, so it + // is debited as before and its amount still moves to Income below. + BigDecimal feeAlreadyRecognised = BigDecimal.ZERO; for (final Map row : collections) { final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense); - insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now, - currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo); + final BigDecimal rowAmount = (BigDecimal) row.get("amount"); + if (chargeColl == null && isIncomeGl(receiptGl)) { + feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount); + continue; + } + insertEntry(officeId, receiptGl, DEBIT, rowAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo); } // Cr Share Capital for the share value. insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share capital issued for " + accountNo); - // Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt - // above -- that money already sits in whichever GL the cashier deposited it to (often the fee - // income account itself), so moving it again here would double it. When it was bundled into - // the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs - // to move, exactly as before. + // Cr Income from Fees for the entry fee, but only for whatever part of it is not already + // sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already + // sits in whichever GL the cashier deposited it to, so moving it again here would double it. + // When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null) + // and genuinely never touched an income account, it still needs to move, exactly as before. if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { if (chargeColl == null) { - // Resolved from the share product's "Income from Fees" mapping, which points at the - // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. - final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); - insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share entry fee for " + accountNo); + final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised); + if (feeStillToRecognise.compareTo(BigDecimal.ZERO) > 0) { + // Resolved from the share product's "Income from Fees" mapping, which points at the + // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. + final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); + insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now, + currencyCode, "Share entry fee for " + accountNo); + } } jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); @@ -1182,6 +1255,24 @@ public class ShareAdvanceCollectionApiResource { return rows.isEmpty() ? fallbackGlId : rows.get(0); } + /** + * Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum} + * for the "Income" account type on {@code acc_gl_account}). + * + *

+ * Used to tell an entry-fee receipt that already sits recognised as income (deposited straight + * into its own income GL by the cashier) apart from one still sitting in a holding account like + * Share Sundry — only the latter still needs moving into income here. + */ + private boolean isIncomeGl(final Long glAccountId) { + if (glAccountId == null) { + return false; + } + final Integer classification = jdbcTemplate.queryForObject( + "SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId); + return classification != null && classification == GL_CLASSIFICATION_INCOME; + } + private Long requireGl(final Long productId, final int financialAccountType, final String label) { final Long glId = resolveGl(productId, financialAccountType); if (glId == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java index 1ac70b446..9f59cd2d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java @@ -2182,6 +2182,90 @@ public class ShareReportsApiResource { return args.toArray(); } + // ══════════════════════════════════════════════════════════════════════ + // GET /product-summary — each share class against its own authorised capital + // ══════════════════════════════════════════════════════════════════════ + /** + * Every share product's authorised capital measured against what has actually been paid up — + * how many of its shares are issued, how many remain, and the distinctive-number range issued so + * far — the numbers a board asks for when it wants to know how close a share class is to its own + * ceiling. + * + *

+ * "Issued" here is paid-up shares, computed the same way every other report in this file computes + * them (settled purchases less redemptions), not {@code m_share_product.totalsubscribed_shares}. + * That column is Fineract's own running count of shares allotted, updated the moment an + * application is submitted — before any money has necessarily arrived — so it already includes + * the allotments this whole file exists to separate out. A board reading "202 of 1,000 issued" + * when the GL Share Capital account shows only 152 shares' worth of money received would be + * planning against a ceiling that is not really as close as the platform's own figure claims. + */ + @GET + @Path("/product-summary") + @Operation(summary = "Share capital issued and remaining, by product", + description = "Authorised shares, paid-up shares issued, shares remaining, unpaid allotments and the distinctive-number range, per share product.") + public Response productSummary() { + this.context.authenticatedUser(); + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sp.id AS "productId", + sp.name AS "productName", + sp.short_name AS "productShortName", + sp.unit_price AS "unitPrice", + sp.minimum_client_shares AS "minimumClientShares", + sp.nominal_client_shares AS "nominalClientShares", + sp.maximum_client_shares AS "maximumClientShares", + COALESCE(sp.total_shares, 0) AS "authorisedShares", + COALESCE(sp.total_shares, 0) * COALESCE(sp.unit_price, 0) AS "authorisedCapital", + COALESCE(issued.shares, 0) AS "issuedShares", + COALESCE(issued.shares, 0) * COALESCE(sp.unit_price, 0) AS "issuedCapital", + COALESCE(sp.total_shares, 0) - COALESCE(issued.shares, 0) AS "availableShares", + COALESCE(unpaid.accounts, 0) AS "unpaidAccounts", + COALESCE(unpaid.shares, 0) AS "unpaidShares", + COALESCE(unpaid.amount, 0) AS "unpaidAmount", + CASE WHEN COALESCE(issued.ever_allotted, 0) > 0 THEN 1 END AS "distinctiveFrom", + NULLIF(COALESCE(issued.ever_allotted, 0), 0) AS "distinctiveTo", + COALESCE(acct."accountCount", 0) AS "accountCount", + COALESCE(acct."memberCount", 0) AS "memberCount" + FROM m_share_product sp + LEFT JOIN ( + SELECT sa.product_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS ever_allotted + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR sa.collection_applied = true) + GROUP BY sa.product_id + ) issued ON issued.product_id = sp.id + LEFT JOIN ( + SELECT sa.product_id, + COUNT(*) AS accounts, + SUM(COALESCE(t.total_shares, 0)) AS shares, + SUM(COALESCE(t.amount, 0)) AS amount + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND t.funding_source IS NULL AND COALESCE(sa.collection_applied, false) = false + AND sa.status_enum <> %d + GROUP BY sa.product_id + ) unpaid ON unpaid.product_id = sp.id + LEFT JOIN ( + SELECT sa.product_id, COUNT(*) AS "accountCount", COUNT(DISTINCT sa.client_id) AS "memberCount" + FROM m_share_account sa + WHERE sa.status_enum = %d + GROUP BY sa.product_id + ) acct ON acct.product_id = sp.id + ORDER BY sp.name + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED, + TXN_APPROVED, TYPE_PURCHASED, ACCOUNT_REJECTED, TXN_APPROVED)); + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + return Response.ok(gson.toJson(out)).build(); + } + private long toLong(final Object value) { return value instanceof Number number ? number.longValue() : 0L; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java index 624162726..b8ebc9cdf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java @@ -108,6 +108,21 @@ public class SponsorDDWritePlatformServiceImpl implements SponsorDDWritePlatform throw new IllegalArgumentException("Series start number must be less than or equal to series end number."); } + // dd_number is globally unique across every inventory batch (sdd_leaf_unq_dd_number), but + // nothing before this checked a fresh batch's own range against numbers already registered -- + // the first colliding number failed the bulk insert below with a raw duplicate-key exception, + // rolling the whole batch back and leaving the counter reading a database stack trace instead + // of the actual problem: this range overlaps one already on file. + final List overlapping = this.jdbcTemplate.queryForList( + "SELECT dd_number FROM m_sponsor_dd_leaflet WHERE dd_number ~ '^[0-9]+$' " + + " AND dd_number::bigint BETWEEN ? AND ? ORDER BY dd_number::bigint LIMIT 5", + String.class, seriesStart, seriesEnd); + if (!overlapping.isEmpty()) { + throw new IllegalArgumentException("Series " + seriesStart + "-" + seriesEnd + + " overlaps DD number(s) already registered in another inventory batch (for example " + overlapping.get(0) + + "). Choose a series that does not overlap an existing one."); + } + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) .orElseThrow(() -> new IllegalArgumentException("Sponsor Bank not found with ID: " + sponsorBankId)); final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(branchId); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 730b2fdd7..0ad5ba659 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -113,4 +113,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..1c0f105ec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From fde26999570698d3a34236e28dcda12e538a0566 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 21 Sep 2026 10:23:52 +0530 Subject: [PATCH 55/55] feat: sponsor bank DD payable GL, AML stale review release, and dividend updates --- .../aml/domain/AmlAlertRepository.java | 6 +- ...RecurringAlertReadPlatformServiceImpl.java | 4 +- .../api/DividendClaimRequestApiResource.java | 37 ++- .../ShareAccountDataSerializer.java | 39 +++ .../api/AllProductsDividendApiResource.java | 230 ++++++++++++++- .../ShareProductDividendAssembler.java | 37 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 42 +++ .../sponsordd/api/SponsorBankApiResource.java | 174 +++++++++-- .../sponsordd/api/SponsorDDApiResource.java | 30 +- .../sponsordd/domain/SponsorBank.java | 15 + ...sorDDIssueRequestWritePlatformService.java | 29 +- ...DIssueRequestWritePlatformServiceImpl.java | 272 ++++++++++++++++-- .../src/main/resources/application.properties | 4 +- .../tenant/final-changelog-tenant.xml | 2 + ...9_aml_alert_release_stale_under_review.xml | 49 ++++ ...0570_add_dd_payable_gl_to_sponsor_bank.xml | 81 ++++++ 16 files changed, 955 insertions(+), 96 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index 1fbefbc3f..29fb8d506 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -49,11 +49,13 @@ public interface AmlAlertRepository extends JpaRepository { * A customer's still-open alerts inside the observation window, oldest first. * *

- * What a recurring-behaviour review acts on. Restricted to RAISED and UNDER_REVIEW because a decision about the + * What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses + * that are open, so a status this query does not know about is still treated as work: an alert wrongly included + * costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the * customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so * that a partial failure leaves the longest-standing ones dealt with. */ @Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since " - + "AND a.status IN ('RAISED', 'UNDER_REVIEW') ORDER BY a.raisedOn ASC") + + "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC") List findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java index bedff9209..d404b8ee6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java @@ -93,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle summary.riskCategory(), alerts.size(), (int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(), (int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(), - (int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(), + // Open means undecided, not a named status: a status this count does not list would otherwise be + // dropped from the customer's open figure while the alert still sits in the feed awaiting review. + (int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(), (int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(), alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add), alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index 2c1d1e584..94dd084d7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -91,6 +91,24 @@ import org.springframework.transaction.annotation.Transactional; @Slf4j public class DividendClaimRequestApiResource { + /** + * The next blank to draw a dividend draft on: the lowest unused number in the register. + * + * Ordered by the printed number, never by row id. Row id is insertion order, which matches the + * series only for the first book registered — a later book allocates from the middle and hands + * out numbers out of sequence, which is what makes an unused number in a run impossible to + * account for. The numeric cast is guarded because a series need not be plain digits; a prefixed + * number sorts as text after the numeric ones, matching how the leaflet register displays it. + * + * SKIP LOCKED so two concurrent claims take different leaflets rather than one waiting on the + * other's transaction. + */ + private static final String LOWEST_AVAILABLE_LEAFLET_SQL = """ + SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' + ORDER BY CASE WHEN dd_number ~ '^[0-9]+$' THEN CAST(dd_number AS BIGINT) END ASC NULLS LAST, dd_number ASC + LIMIT 1 FOR UPDATE SKIP LOCKED + """; + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -191,11 +209,12 @@ public class DividendClaimRequestApiResource { .orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has " + "just taken it. Go back and choose another leaflet.")) // No leaflet named (a caller that is not the leaflets screen): fall back to the - // oldest available, which is the order a series is meant to be consumed in. - : jdbcTemplate.queryForList(""" - SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' - ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED - """).stream().findFirst().orElseThrow(() -> apiError(409, + // lowest unused number, which is the order a series is meant to be consumed in. + // Ordered by the printed number rather than by row id — id is insertion order, and + // the two diverge as soon as a second book is registered, which hands out numbers + // from the middle of a series instead of its start. + : jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL) + .stream().findFirst().orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.")); final Long leafletId = ((Number) leaflet.get("id")).longValue(); final String ddNumber = (String) leaflet.get("dd_number"); @@ -430,11 +449,9 @@ public class DividendClaimRequestApiResource { final Map record, final BigDecimal amount, final String currencyCode, final LocalDate today, final AppUser currentUser) { - // Oldest series first, and locked so two concurrent approvals cannot take the same leaflet. - final Map leaflet = jdbcTemplate.queryForList(""" - SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' - ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED - """).stream().findFirst() + // Lowest unused number first, and locked so two concurrent approvals cannot take the same + // leaflet. + final Map leaflet = jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL).stream().findFirst() .orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory.")); final Long leafletId = ((Number) leaflet.get("id")).longValue(); final String ddNumber = (String) leaflet.get("dd_number"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 29bbb45d2..1c58cfd3a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -166,6 +166,8 @@ public class ShareAccountDataSerializer { baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull() .longGreaterThanZero(); + validateNoOpenAccountForProduct(clientId, productId, baseDataValidator); + if (shareProduct.getMinimumClientShares() != null && requestedShares < shareProduct.getMinimumClientShares()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).failWithCode( "client.can.not.purchase.shares.lessthan.product.definition", @@ -545,6 +547,43 @@ public class ShareAccountDataSerializer { return actualChanges; } + /** + * A member may hold only one share account per product at a time. + * + * The society's shareholding in a product is one relationship, not several: a member's stake, their dividend and + * their voting entitlement are all read from it, so a second open account splits the same person's holding across + * two records and makes each of those figures ambiguous. It also lets the product's maximum-shares-per-client + * ceiling be passed by opening a second account, since that check only ever sees the account in front of it. + * + * Closed and rejected accounts do not block a new one. A member who closed their account and later rejoins is + * starting a fresh relationship, and a rejected application never became one — neither leaves a holding behind. + * Everything else does: an account still awaiting approval, or approved but not yet paid for, is a commitment the + * society has already recorded, so a second application on top of it is refused rather than quietly allowed. + */ + private void validateNoOpenAccountForProduct(final Long clientId, final Long productId, + final DataValidatorBuilder baseDataValidator) { + if (clientId == null || productId == null) { + return; + } + // 500 REJECTED and 600 CLOSED are the two states that leave no holding behind; see + // ShareAccountStatusType for the full set. + final List> open = this.jdbcTemplate.queryForList(""" + SELECT account_no, status_enum FROM m_share_account + WHERE client_id = ? AND product_id = ? AND status_enum NOT IN (500, 600) + ORDER BY id + """, clientId, productId); + if (open.isEmpty()) { + return; + } + // The existing account number is named so the officer can go and look at it, rather than + // being told only that one exists somewhere. + final String existing = open.get(0).get("account_no") == null ? "" : String.valueOf(open.get(0).get("account_no")); + baseDataValidator.reset().parameter(ShareAccountApiConstants.productid_paramname).value(productId).failWithCode( + "client.already.has.an.open.share.account.for.this.product", + "This member already holds share account " + existing + + " in this product. Close that account before opening another, or add shares to it instead."); + } + private void validateTotalSubsribedShares(final ShareAccount account, final ShareAccountTransaction transaction, final DataValidatorBuilder baseDataValidator) { Long totalSubsribedShares = account.getShareProduct().getSubscribedShares(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 0b7f8fe02..8b85f94a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -35,15 +35,21 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.common.DividendGlAccountResolver; 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.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -72,6 +78,7 @@ import org.springframework.stereotype.Component; @Component @Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.") @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class AllProductsDividendApiResource { @@ -96,6 +103,7 @@ public class AllProductsDividendApiResource { private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── GET /v1/shareproducts/dividends ── Every declaration, across products ── @@ -176,6 +184,8 @@ public class AllProductsDividendApiResource { entry.put("productName", capital.productName); entry.put("weightedCapital", capital.weightedCapital); entry.put("amount", amount); + entry.put("memberCount", capital.memberCount); + entry.put("accountCount", capital.accountCount); entry.put("eligible", capital.weightedCapital.signum() > 0); entry.put("reason", capital.weightedCapital.signum() > 0 ? null : "No member has held shares in this product long enough to qualify for this period."); @@ -191,6 +201,21 @@ public class AllProductsDividendApiResource { out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); out.put("ratePercentage", ratePercentage); out.put("totalWeightedCapital", totalWeightedCapital); + // Distinct across products: one person holding shares in both is one member receiving a + // dividend, not two, so summing the per-product counts would overstate the membership. + out.put("totalMemberCount", distinctMemberCount(from, to)); + // Accounts simply add up, unlike members: each holding is allocated, locked and claimed on + // its own, so a person with two of them appears twice here and once in the member count. + out.put("totalAccountCount", capitals.stream().mapToInt(c -> c.accountCount).sum()); + // Reported rather than thrown, so the officer sees why the year they picked cannot be + // declared while the figures are still on screen. The POST enforces the same rule. + out.put("periodNotFinished", !to.isBefore(DateUtils.getBusinessLocalDate())); + // The board's own cap, reported so the screen can flag a rate above it while it can still be + // changed. The POST enforces the same limit. + final BigDecimal rateCeiling = configuredRateCeiling(); + out.put("maximumRatePercentage", rateCeiling); + out.put("exceedsMaximumRate", + rateCeiling != null && rateCeiling.signum() > 0 && ratePercentage != null && ratePercentage.compareTo(rateCeiling) > 0); out.put("profitGlCode", distributableProfitGlCode()); // The funding position, not the basis. Carried so a rate the profit account cannot cover is // visible on screen before submission — the command layer enforces the same ceiling, and an @@ -238,6 +263,8 @@ public class AllProductsDividendApiResource { if (rate.signum() <= 0) { throw apiError(400, "ratePercentage must be greater than zero"); } + rejectIfPeriodNotFinished(to); + rejectIfRateAboveCeiling(rate); final List capitals = capitalPerProduct(from, to); @@ -276,8 +303,27 @@ public class AllProductsDividendApiResource { payload.addProperty("dividendAmount", amount); try { + // Each product gets its own idempotency key. Without one, every command in this loop + // inherits the single key held against the HTTP request, so the first product's + // declaration claims it and every product after is rejected as already processed -- + // the second product silently received nothing while the first went through. + // + // Freshly generated per command rather than derived from the period and rate: a key + // built from those is the same on every run, so once a declaration had been made and + // then reverted, redeclaring the same year at the same rate was refused as a + // duplicate of the reverted one. Declaring twice for a period is already prevented + // properly, by the overlap check against existing declarations, which looks at what + // is actually on the books rather than at what was once submitted. + final String idempotencyKey = java.util.UUID.randomUUID().toString(); + // The command layer records the command it just ran against the HTTP request, and + // treats any command that follows within the same request as a retry of it — + // reusing the first command's idempotency key and rejecting the rest as already + // processed. Cleared before each product so every declaration in this loop is + // executed on its own terms rather than as a repeat of the one before it. + fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId) - .withJson(payload.toString()).build(); + .withJson(payload.toString()).build(idempotencyKey); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); // The rate the board actually resolved, recorded as declared. Written here rather @@ -296,6 +342,10 @@ public class AllProductsDividendApiResource { row.put("dividendId", result.getSubResourceId()); created.add(row); } catch (final RuntimeException e) { + // Logged with the stack trace: the reason reported below comes from the exception's + // message, and several of the failures here carry none, leaving "Could not declare + // for this product" as the only trace of a product that silently received nothing. + log.error("Dividend declaration failed for product {} ({})", productId, productName, e); // Most often the distributable-profit ceiling: an earlier product in this same run has // already consumed the pool. Reported per product so the officer can see which ones // went through and why the rest did not, instead of losing all of them to one failure. @@ -319,31 +369,56 @@ public class AllProductsDividendApiResource { // ── Helpers ──────────────────────────────────────────────────────────── - /** One product's weighted paid-up capital for the period. */ + /** One product's weighted paid-up capital for the period, and how many members it covers. */ private static final class ProductCapital { private final Long productId; private final String productName; private final BigDecimal weightedCapital; + /** + * Members whose holdings make up that capital. + * + * Carried so the preview can say who a product's amount is actually spread across. An amount + * alone does not tell the officer whether it reaches the membership they expect — a product + * showing a plausible total against one member is the case worth catching before approval, + * not after. + */ + private final int memberCount; + /** + * Accounts that capital is spread across. + * + * Tracked alongside the member count because one person may hold shares in more than one + * product, and each holding earns and is claimed separately. The two numbers differ exactly + * when that happens, so reporting both is what tells an officer the split is intentional + * rather than a miscount. + */ + private final int accountCount; - private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) { + private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital, + final int memberCount, final int accountCount) { this.productId = productId; this.productName = productName; this.weightedCapital = weightedCapital; + this.memberCount = memberCount; + this.accountCount = accountCount; } } /** Weighted paid-up capital for every share product, in one pass. */ private List capitalPerProduct(final LocalDate from, final LocalDate to) { final List capitals = new ArrayList<>(); - for (final Map product : jdbcTemplate - .queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) { + for (final Map product : jdbcTemplate.queryForList( + "SELECT id, name, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product ORDER BY id")) { final Long productId = ((Number) product.get("id")).longValue(); final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); - final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + final Map holding = weightedCapitalDays(productId, from, to, minimumActivePeriod, allowInactiveClients); + final BigDecimal capitalDays = (BigDecimal) holding.get("capital_days"); + final int memberCount = ((Number) holding.get("member_count")).intValue(); + final int accountCount = ((Number) holding.get("account_count")).intValue(); capitals.add(new ProductCapital(productId, (String) product.get("name"), - capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP))); + capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP), memberCount, accountCount)); } return capitals; } @@ -466,19 +541,144 @@ public class AllProductsDividendApiResource { * measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the * obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing. */ - private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, - final int minimumActivePeriod) { - final BigDecimal total = jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) + private Map weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, + final int minimumActivePeriod, final boolean allowInactiveClients) { + // A closed account still earned dividend for the part of the period it was open, so when the + // product allows inactive clients it is included exactly as the declaration includes it -- + // ACTIVE, or CLOSED after the period began. Filtering to ACTIVE alone was what made this + // preview disagree with the declaration it previews: a product whose members had since closed + // their accounts showed a smaller capital here than the allocation actually paid out on, and + // the rate the officer saw was not the rate the members received. + final String statusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final List params = new ArrayList<>(); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(productId); + if (allowInactiveClients) { + params.add(java.sql.Date.valueOf(from)); + } + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(minimumActivePeriod); + + // Capital and member count come out of one query against one set of rows, so the two can + // never describe different populations -- a count derived separately would drift from the + // amount it is meant to explain the moment either query's filters were touched. + final Map row = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) AS capital_days, + COUNT(DISTINCT sa.client_id) AS member_count, + COUNT(DISTINCT sa.id) AS account_count FROM m_share_account sa JOIN m_share_account_transactions t ON t.account_id = sa.id - WHERE sa.product_id = ? AND sa.status_enum = 300 + WHERE sa.product_id = ? + """ + statusClause + """ AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND COALESCE(t.amount_paid, 0) > 0 AND t.transaction_date <= ? AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? - """, BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to), - java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod); - return total == null ? BigDecimal.ZERO : total; + """, params.toArray()); + return row; + } + + /** + * Refuses a declaration for a period that has not finished yet. + * + * A dividend is a share of the year's profit, and until the last day of the period has passed + * that profit is not a settled figure — declaring against it commits the society to paying out of + * a number that can still move with every transaction booked before 31 March. Each member's + * entitlement is also weighted by how long they held shares across the period, which cannot be + * known while days remain in it: a member joining in February would be paid as though the year + * ended today. + * + * Compared against the business date, not the server clock, so a branch operating on a back-dated + * business date is held to that date. + */ + private void rejectIfPeriodNotFinished(final LocalDate to) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (!to.isBefore(businessDate)) { + throw apiError(400, "The period ending " + to + " has not finished yet — a dividend can only be declared once the " + + "financial year is over and its profit is final. The current business date is " + businessDate + "."); + } + } + + /** + * Refuses a rate above the society's configured ceiling. + * + * {@code dividend-maximum-rate-percentage} is the board's own cap on what may be paid in a year — + * a co-operative society's bye-laws set one, and exceeding it needs a resolution rather than a + * typed figure. It was read only by the distributable-profit screen, which reported it without + * anything enforcing it, so a rate above the cap went through unchallenged. + * + * A missing or disabled configuration row means the society has set no ceiling, which is not a + * failure — the rate then stands on the profit ceiling alone. + */ + private void rejectIfRateAboveCeiling(final BigDecimal rate) { + final BigDecimal ceiling = configuredRateCeiling(); + if (ceiling != null && ceiling.signum() > 0 && rate.compareTo(ceiling) > 0) { + throw apiError(400, "A rate of " + rate.stripTrailingZeros().toPlainString() + "% is above the society's maximum of " + + ceiling.stripTrailingZeros().toPlainString() + "%. Lower the rate, or raise the limit in " + + "Configurations (dividend-maximum-rate-percentage) if the board has resolved a higher one."); + } + } + + /** The society's configured maximum rate, or null when no ceiling is set. */ + private BigDecimal configuredRateCeiling() { + final List> rows = jdbcTemplate.queryForList( + "SELECT value FROM c_configuration WHERE name = 'dividend-maximum-rate-percentage' AND enabled = true"); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return null; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + + /** + * How many distinct members this rate would reach across every product. + * + * Each product is measured under its own rules — a product's minimum active period and whether it + * pays inactive clients both change who qualifies — and the resulting client ids are then unioned, + * so somebody holding shares in two products counts once. Summing the per-product counts instead + * would report more members than exist. + */ + private int distinctMemberCount(final LocalDate from, final LocalDate to) { + final Set members = new HashSet<>(); + for (final Map product : jdbcTemplate.queryForList( + "SELECT id, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product")) { + final Long productId = ((Number) product.get("id")).longValue(); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + + final String statusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final List params = new ArrayList<>(); + params.add(productId); + if (allowInactiveClients) { + params.add(java.sql.Date.valueOf(from)); + } + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(minimumActivePeriod); + + members.addAll(jdbcTemplate.queryForList(""" + SELECT DISTINCT sa.client_id + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? + """ + statusClause + """ + AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND COALESCE(t.amount_paid, 0) > 0 + AND t.transaction_date <= ? + AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? + """, Long.class, params.toArray())); + } + return members.size(); } private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java index b08c8c70a..5e623df47 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java @@ -59,8 +59,13 @@ public class ShareProductDividendAssembler { ShareProductData product = (ShareProductData) this.shareProductReadPlatformService.retrieveOne(productId, false); MonetaryCurrency currency = new MonetaryCurrency(product.getCurrency().getCode(), product.getCurrency().getDecimalPlaces(), product.getCurrency().getInMultiplesOf()); - Collection shareAccountDatas = this.shareAccountReadPlatformService.retrieveAllShareAccountDataForDividends( - productId, product.getAllowDividendCalculationForInactiveClients(), dividendPeriodStartDate); + // Defaulted rather than unboxed directly: the flag is a nullable Boolean, and a product + // saved before the column existed carries null, which throws a NullPointerException on + // unboxing. That exception surfaced as "Could not declare for this product" with no reason + // attached, dropping the product from the run while the others went through. + final boolean allowInactiveClients = Boolean.TRUE.equals(product.getAllowDividendCalculationForInactiveClients()); + Collection shareAccountDatas = this.shareAccountReadPlatformService + .retrieveAllShareAccountDataForDividends(productId, allowInactiveClients, dividendPeriodStartDate); if (shareAccountDatas == null || shareAccountDatas.isEmpty()) { throw new ShareAccountsNotFoundException(product.getId()); } @@ -83,6 +88,15 @@ public class ShareProductDividendAssembler { dividendPeriodStartDate, dividendPeriodEndDate); for (ShareAccountData accountData : shareAccountDatas) { long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId()); + // No share-days means the account held nothing inside this period at all -- its + // purchase falls after the period end, or never cleared the minimum active period, + // or was never paid for. Such an account is not a member of this dividend, so it + // gets no row: listing it would put a nil entitlement in front of a checker, count + // it in the declaration's member total, and report it as "below the minimum payable + // amount" when the truth is that it does not belong to this year at all. + if (numberOfShareDaysPerAccount <= 0) { + continue; + } double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay; // Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87 // becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts @@ -150,7 +164,26 @@ public class ShareProductDividendAssembler { final PurchasedSharesStatusType type = PurchasedSharesStatusType.fromInt(purchasedSharesData.getType().getId().intValue()); if (status.isApproved() && !type.isChargePayment()) { + // An approved purchase the member never paid for earns nothing. Approval is the + // society agreeing to allot the shares; the dividend is a return on capital that + // was actually brought in, so shares still awaiting payment are not capital yet. + // Counting them would pay a member out of everyone else's money for a holding + // they have not funded, and would dilute every paid-up member's entitlement. + if (type.isPurchased() && (purchasedSharesData.getAmountPaid() == null + || purchasedSharesData.getAmountPaid().signum() <= 0)) { + continue; + } + LocalDate shareStartDate = purchasedSharesData.getPurchasedDate(); + // A purchase made after the period ends belongs to a later year and has no + // holding inside this one. Left in, it is measured from its own date back to the + // period end — a negative span — and those negative share-days are subtracted + // from the product's total, shrinking every member's entitlement to pay for + // shares nobody held during the year. A 20-May-2026 purchase counted -2,450 + // share-days against FY 2025-26 here. + if (DateUtils.isAfter(shareStartDate, postingDate)) { + continue; + } if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) { shareStartDate = lastDividendPostDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index f35f10d21..b2ce9c6b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -188,6 +188,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final BigDecimal dividendAmount = this.fromApiJsonHelper .extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element); + validatePeriodHasFinished(dividendPeriodEndDate); validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate); validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount); final String financialYear = financialYearOf(dividendPeriodEndDate); @@ -227,6 +228,26 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * that period's profit. Two declarations covering the same days would pay members twice for the same period, so any * overlap against an existing declaration for this product is blocked outright. */ + /** + * A dividend may only be declared once the period it covers is over. + * + * The payout is a share of that period's profit, and until the last day has passed the profit is + * not final — every transaction still to be booked moves the figure the declaration was sized on. + * Each member's entitlement is weighted by how long they held shares across the period too, which + * cannot be settled while days remain in it. + * + * Enforced here rather than only on the screen because this command is the single point every + * declaration passes through, including one posted straight to the API. + */ + private void validatePeriodHasFinished(final LocalDate dividendPeriodEndDate) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (!dividendPeriodEndDate.isBefore(businessDate)) { + throw new DividentProcessingException("period.not.finished", + "The period ending " + dividendPeriodEndDate + " has not finished yet, so its profit is not final. A dividend can " + + "only be declared once the financial year is over. The current business date is " + businessDate + "."); + } + } + private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { final Integer overlappingCount = jdbcTemplate.queryForObject(""" @@ -300,6 +321,27 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount(); final Long settledAccountId = dividendGlAccounts.retainedProfitAccount(); + // The year's leftover profit is closed off once, not once per product. + // + // Every product's approval arrives here and computes the same society-wide remainder, so + // approving a second product for the same year moved that remainder to Bank Profit a second + // time -- 73,454.80 went across twice and left the Non-Distributable account holding a debit + // balance, money the society had swept out but never had. Each transaction balanced on its + // own, so the trial balance stayed at zero and hid it. + // + // Detected from the ledger rather than a flag on the declaration, because the entry is what + // actually moved the money: a close-off already posted for this financial year means this + // year's remainder is settled, whichever product's approval posted it. + final String financialYearLabel = financialYearOf(periodEndDate); + final Integer alreadyClosedOff = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_gl_journal_entry + WHERE reversed = false AND account_id = ? + AND description LIKE ? + """, Integer.class, distributableAccountId, "FY " + financialYearLabel + " undistributed profit closed off%"); + if (alreadyClosedOff != null && alreadyClosedOff > 0) { + return; + } + final String financialYear = financialYearOf(periodEndDate); // Dated to the day of approval, not the period end. Closing off the remainder is an act of // the approval, so it must appear in the ledger on the day the officer performed it — diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java index c006f010a..a78c88407 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -27,13 +27,17 @@ 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.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.time.LocalDateTime; import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.function.Supplier; import lombok.AllArgsConstructor; import lombok.Data; @@ -56,6 +60,7 @@ public class SponsorBankApiResource { private final SponsorBankRepository sponsorBankRepository; private final PlatformSecurityContext context; + private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; private final Gson gson = new GsonBuilder().serializeNulls().create(); /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ @@ -63,31 +68,103 @@ public class SponsorBankApiResource { try { return action.get(); } catch (final IllegalArgumentException | IllegalStateException e) { - final JsonObject body = new JsonObject(); - body.addProperty("message", e.getMessage()); - body.addProperty("defaultUserMessage", e.getMessage()); - body.addProperty("httpStatus", 400); - final JsonArray errors = new JsonArray(); - final JsonObject err = new JsonObject(); - err.addProperty("defaultUserMessage", e.getMessage()); - errors.add(err); - body.add("errors", errors); - throw new WebApplicationException( - Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + throw badRequest(e.getMessage()); + } catch (final org.springframework.dao.DataIntegrityViolationException e) { + // A constraint the form cannot pre-empt - in practice the unique bank name. Reported as + // the 400 it is, rather than surfacing as an "Internal Server Error" that tells an + // administrator nothing about what to change. + final String detail = e.getMostSpecificCause().getMessage(); + throw badRequest(detail != null && detail.toLowerCase().contains("name") + ? "A sponsor bank with this name already exists. Bank names must be unique." + : "This sponsor bank could not be saved: " + detail); + } catch (final org.springframework.jdbc.BadSqlGrammarException e) { + // Almost always a pending schema change: the DD Payable column arrives with changelog + // 0568, and a server started before it was applied has no such column. Said plainly so + // the fix (restart/migrate) is obvious rather than looking like a code fault. + throw badRequest("The sponsor bank tables are not up to date with this build - the DD Payable column is missing." + + " Restart the server so the pending database migrations are applied, then try again."); } } + private WebApplicationException badRequest(final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + @GET @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Retrieve all Sponsor Banks", description = "Returns a list of all registered Sponsor Banks.") public Collection retrieveAllSponsorBanks() { + // The payable account's code and name travel with each bank so the list can show which GL a + // bank's drafts are credited to, and flag a bank that has none, without a second round trip. + final Map glNames = loadGlAccountNames(); return this.sponsorBankRepository.findAll().stream() - .map(sb -> new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), - sb.getSwiftCode())) + .map(sb -> { + final String[] gl = sb.getDdPayableGlAccountId() == null ? null : glNames.get(sb.getDdPayableGlAccountId()); + return new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), + sb.getSwiftCode(), sb.getDdPayableGlAccountId(), gl == null ? null : gl[0], gl == null ? null : gl[1]); + }) .toList(); } + private Map loadGlAccountNames() { + final Map names = new java.util.HashMap<>(); + for (final Map row : this.jdbcTemplate.queryForList("SELECT id, gl_code, name FROM acc_gl_account")) { + names.put(((Number) row.get("id")).longValue(), new String[] { (String) row.get("gl_code"), (String) row.get("name") }); + } + return names; + } + + /** + * Rejects a payable account that cannot serve as one. + * + * A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be + * a liability account, and it has to accept postings. Catching that here means an administrator + * is told while choosing the account, rather than a clerk discovering it when a draft fails. + */ + private void validateDdPayableGlAccount(final Long glAccountId) { + if (glAccountId == null) { + return; + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", + glAccountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); + } + final Map gl = rows.get(0); + final String label = gl.get("gl_code") + " · " + gl.get("name"); + // 2 is LIABILITY in Fineract's GLAccountType enum. + if (((Number) gl.get("classification_enum")).intValue() != 2) { + throw new IllegalArgumentException( + "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " + + label + " is not a liability account."); + } + // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. + if (((Number) gl.get("account_usage")).intValue() == 2) { + throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " + + label + "."); + } + // Read through both shapes: the column comes back as a Boolean on some drivers and as a + // 0/1 Number on others, and a plain Boolean cast would throw on the latter. + final Object disabled = gl.get("disabled"); + final boolean isDisabled = disabled instanceof Boolean b ? b + : disabled instanceof Number n && n.intValue() != 0; + if (isDisabled) { + throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); + } + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @@ -99,16 +176,63 @@ public class SponsorBankApiResource { throw new IllegalArgumentException( "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("Sponsor Bank Name is required."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); final AppUser currentUser = this.context.authenticatedUser(); - final SponsorBank sponsorBank = new SponsorBank( - request.getName(), - request.getDescription(), - request.getIfscCode() != null ? request.getIfscCode().trim().toUpperCase() : null, - request.getBankCode(), - request.getSwiftCode() != null ? request.getSwiftCode().trim().toUpperCase() : null, - currentUser, - LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) - ); + // Set field by field rather than through the all-args constructor: that constructor is + // positional, so adding a column silently shifts every argument after it, and the shift + // type-checks whenever two neighbours share a type. + final SponsorBank sponsorBank = new SponsorBank(); + sponsorBank.setName(request.getName().trim()); + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + sponsorBank.setCreatedBy(currentUser); + sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + }); + } + + /** + * Edits a sponsor bank, which in practice is mostly about pointing it at its DD Payable account. + * + * Existing banks predate the per-bank payable and have to be given one before they can issue any + * further drafts, and an account repointed at the wrong GL has to be correctable without + * deleting a bank that inventory and issued drafts already reference. + */ + @PUT + @Path("{sponsorBankId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.") + public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId, + final SponsorBankRequest request) { + return safely(() -> { + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + if (request.getName() != null && !request.getName().isBlank()) { + sponsorBank.setName(request.getName().trim()); + } + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); this.sponsorBankRepository.saveAndFlush(sponsorBank); return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); }); @@ -124,6 +248,11 @@ public class SponsorBankApiResource { private String ifscCode; private String bankCode; private String swiftCode; + /** The GL account drafts on this bank are credited to; null until an administrator sets it. */ + private Long ddPayableGlAccountId; + /** That account's code and name, so a caller can show it without fetching the chart. */ + private String ddPayableGlCode; + private String ddPayableGlName; } @Data @@ -135,5 +264,6 @@ public class SponsorBankApiResource { private String ifscCode; private String bankCode; private String swiftCode; + private Long ddPayableGlAccountId; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index 3309b249c..dfa5cba14 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -288,7 +288,7 @@ public class SponsorDDApiResource { @Path("inventory/{inventoryId}/issue-request-batch") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit or cheque funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") + @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit, cheque or GL account debit funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") public SponsorDDBatchIssueResult submitIssueRequestBatch(@PathParam("inventoryId") final Long inventoryId, final IssueRequestBatchRequest request) { final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); @@ -307,6 +307,7 @@ public class SponsorDDApiResource { request.getCurrentAccountId(), request.getChequeNumber(), request.getChequeBankName(), + request.getGlAccountId(), lines )); } @@ -315,9 +316,15 @@ public class SponsorDDApiResource { @Path("issue-requests/batch/{batchId}/approve") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting for the whole slip's total, then issues every draft in it. Approves every line together, not one at a time.") - public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId) { - return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId)); + @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting, then issues the slip's drafts. With no body (or no approvedRequestIds) the whole slip is issued. Supplying approvedRequestIds issues only those drafts, debits only for them, and rejects the rest of the slip - their DD numbers return to stock.") + public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId, + final ApproveIssueRequestBatchRequest request) { + // An empty list is read as "no selection made" rather than "approve nothing": the existing + // callers post an empty body, and a slip that issued no drafts at all is a rejection, which + // has its own endpoint. + final List requested = request == null ? null : request.getApprovedRequestIds(); + final List approvedRequestIds = requested == null || requested.isEmpty() ? null : requested; + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId, approvedRequestIds)); } @POST @@ -684,11 +691,13 @@ public class SponsorDDApiResource { private Long clientId; private Long issuingBranchId; private String issuedDate; - private String paymentMethod; // ACCOUNT_DEBIT or CHEQUE only — a slip does not support CASH + private String paymentMethod; // ACCOUNT_DEBIT, CHEQUE or GL — a slip does not support CASH private Long savingsAccountId; private Long currentAccountId; private String chequeNumber; private String chequeBankName; + /** The GL account a GL-funded slip is debited from; unused by every other payment method. */ + private Long glAccountId; private List lines; } @@ -706,6 +715,17 @@ public class SponsorDDApiResource { private String rejectionReason; } + /** + * Which drafts of a slip the customer still wants. + * + * Null or empty means all of them, so an existing caller that posts an empty body keeps issuing + * the whole slip exactly as before. + */ + @lombok.Data + public static class ApproveIssueRequestBatchRequest { + private List approvedRequestIds; + } + @lombok.Data public static class ReprintRequestRequest { private String reason; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java index 59db2f8d7..e6adaf283 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java @@ -54,6 +54,21 @@ public class SponsorBank extends AbstractPersistableCustom { @Column(name = "swift_code", length = 11) private String swiftCode; + /** + * The GL account a draft drawn on this bank is credited to. + * + * A draft drawn on SBI is owed to SBI and one drawn on HDFC is owed to HDFC, so what is + * outstanding to either can only be answered if each bank credits its own payable - which is + * precisely the figure a settlement with that bank is reconciled against. Held here rather than + * in the global DD GL mapping for that reason; charges income, GST and the clearing accounts + * stay there, being this bank's own income and tax whichever sponsor the draft is drawn on. + * + * Nullable only so an existing bank can be set up after upgrading. Issuing a draft on a bank + * with none configured is refused rather than falling back to another bank's account. + */ + @Column(name = "dd_payable_gl_account_id") + private Long ddPayableGlAccountId; + @ManyToOne @JoinColumn(name = "createdby_id", nullable = false) private AppUser createdBy; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java index 1e1315337..0d8f39e94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -55,19 +55,20 @@ public interface SponsorDDIssueRequestWritePlatformService { CommandProcessingResult rejectIssueRequest(Long issueRequestId, String rejectionReason); /** - * Clerk (maker) step for a multi-DD "slip": one customer account debit (or one cheque) funds - * several drafts in one submission - a physical leaflet is auto-allocated per line from the given - * inventory, and every resulting row shares one batch id so the checker approves/rejects the whole - * slip as a unit rather than line by line. CASH is not supported here - only ACCOUNT_DEBIT/CHEQUE, - * since a cash slip would also need Transaction Entry changes to record one lump intake for N - * drafts, which is a separate piece of work. Returns exactly which physical leaflet (DD number) - * was auto-allocated to each line and for what amount, since that allocation only happens inside - * this call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the + * Clerk (maker) step for a multi-DD "slip": one customer account debit, one cheque, or one GL + * account debit funds several drafts in one submission - a physical leaflet is auto-allocated per + * line from the given inventory, and every resulting row shares one batch id so the checker + * approves/rejects the whole slip as a unit rather than line by line. CASH is not supported here - + * a cash slip would also need Transaction Entry changes to record one lump intake for N drafts, + * and a single counted pile of notes cannot be split across drafts without the denomination total + * ceasing to prove what was actually taken. Returns exactly which physical leaflet (DD number) was + * auto-allocated to each line and for what amount, since that allocation only happens inside this + * call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the * caller beforehand. */ SponsorDDBatchIssueResult submitIssueRequestBatch(Long inventoryId, Long clientId, Long issuingBranchId, LocalDate issuedDate, String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, - List lines); + Long glAccountId, List lines); /** * Checker step for a multi-DD slip: one combined withdrawal for the slip's total face value, one @@ -77,6 +78,16 @@ public interface SponsorDDIssueRequestWritePlatformService { */ CommandProcessingResult approveIssueRequestBatch(Long batchId); + /** + * As above, but issuing only the drafts the customer still wants. + * + * approvedRequestIds null approves the whole slip (identical to the overload above). Otherwise + * only those rows are issued, the debit covers only them, and the rest of the slip is rejected in + * the same transaction - their leaflets were never taken off AVAILABLE, so those DD numbers + * return to stock for the next customer rather than leaving a gap in the series. + */ + CommandProcessingResult approveIssueRequestBatch(Long batchId, List approvedRequestIds); + /** Rejects every line of a pending multi-DD slip together; no money has moved yet for ACCOUNT_DEBIT/CHEQUE. */ CommandProcessingResult rejectIssueRequestBatch(Long batchId, String rejectionReason); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index 590378db0..cda677df1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -189,7 +189,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI if (glAccountId == null) { throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance."); } - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // The sponsor bank's own payable, since that is what this draft will be credited to. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflet(leafletId); if (glAccountId.equals(ddPayableGlAccountId)) { final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account (" @@ -268,12 +269,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI @Transactional public SponsorDDBatchIssueResult submitIssueRequestBatch(final Long inventoryId, final Long clientId, final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, - final String chequeNumber, final String chequeBankName, final List lines) { + final String chequeNumber, final String chequeBankName, final Long glAccountId, final List lines) { final AppUser currentUser = this.context.authenticatedUser(); - if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod)) { - throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT or CHEQUE payment methods."); + // CASH alone is excluded: one counted pile of notes cannot be apportioned across drafts + // without the denomination total ceasing to prove what was actually taken at the counter. + if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod) + && !METHOD_GL.equals(paymentMethod)) { + throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT, CHEQUE or GL payment methods."); } if (lines == null || lines.isEmpty()) { throw new IllegalArgumentException("At least one DD line is required."); @@ -302,6 +306,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, null, null, null); + // A GL-funded slip is raised against no member at all, so clientId is legitimately null here + // (validatePaymentMethodFields refuses one being supplied). Every other method requires it. + if (!METHOD_GL.equals(paymentMethod) && clientId == null) { + throw new IllegalArgumentException("Client ID is required for this DD slip."); + } + final BigDecimal totalFaceValue = lines.stream().map(SponsorDDSlipLine::faceValue).reduce(BigDecimal.ZERO, BigDecimal::add); final BigDecimal totalCharges = lines.stream() .map(l -> l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO) @@ -342,6 +352,23 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .map(l -> computeGst(l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO, gstPercentage)) .reduce(BigDecimal.ZERO, BigDecimal::add); validateSufficientBalance(savingsAccountId, currentAccountId, totalFaceValue.add(totalCharges).add(totalGstForBalanceCheck)); + } else if (METHOD_GL.equals(paymentMethod)) { + // No client and no member account - an institutional slip funded straight from a GL + // account, mirroring the single-DD GL path. Debiting the same account the drafts are + // credited to (DD Payable) would be a self-cancelling entry with no real movement, so it + // is refused here exactly as it is for a single draft. A GL account carries no balance + // check, so there is nothing to validate the slip total against. + if (glAccountId == null) { + throw new IllegalArgumentException("GL Account is required for a GL-funded DD slip."); + } + // Read from the inventory's sponsor bank: the slip's leaflets have not been allocated + // yet at this point, and they all come from this one inventory anyway. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForInventory(inventoryId); + if (glAccountId.equals(ddPayableGlAccountId)) { + final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); + throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account (" + + ddPayable.get("gl_code") + ") these drafts are credited to — pick a different GL account to debit."); + } } else { validateClientExists(clientId); } @@ -366,11 +393,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "(leaflet_id, client_id, beneficiary_name, payable_at_branch, payable_at_city, face_value, " + "charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, payment_method, " + "savings_account_id, current_account_id, cheque_number, cheque_bank_name, status, maker_id, " - + "created_date, batch_id) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "created_date, batch_id, gl_account_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); ps.setLong(1, leafletId); - ps.setLong(2, clientId); + // Null on a GL-funded slip, which is raised against no member — setLong would throw. + if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); ps.setString(3, line.beneficiaryName()); ps.setString(4, line.payableAtBranch()); ps.setString(5, line.payableAtCity()); @@ -389,6 +417,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI ps.setLong(18, currentUser.getId()); ps.setObject(19, now); if (batchIdForThisRow != null) ps.setLong(20, batchIdForThisRow); else ps.setNull(20, java.sql.Types.BIGINT); + // The GL account this slip is funded from, read back by the checker to post the debit + // leg — the same column and the same meaning as on a single GL-funded draft. + if (glAccountId != null) ps.setLong(21, glAccountId); else ps.setNull(21, java.sql.Types.BIGINT); return ps; }, kh); final long newId = Objects.requireNonNull(kh.getKey()).longValue(); @@ -402,15 +433,40 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return new SponsorDDBatchIssueResult(batchId, resultLines); } - /** Locks and returns the next `count` AVAILABLE leaflets in this inventory, oldest series first. */ + /** + * Locks and returns the next `count` leaflets in this inventory that are free to allocate, oldest + * series first. + * + * Free means AVAILABLE *and* not already spoken for by a pending request. A leaflet keeps its + * AVAILABLE status from submission right up to approval - nothing marks it reserved in between - + * so status alone would hand the same DD number to a second slip while the first was still + * waiting on a checker. Both would then be approved against one physical form, and the second + * customer's draft would carry a number already issued to somebody else. The single-DD path + * refuses a leaflet with a pending request against it for exactly this reason; a slip has to + * apply the same test, and does it here in the same locked statement that picks the run. + */ private List> allocateAvailableLeaflets(final Long inventoryId, final int count) { + // Ordered by the printed number, not by row id. + // + // A book's blanks have to be consumed in series order - that is what makes an unused number + // in the middle of a run a thing anyone notices, and it is the order the physical forms sit + // in the clerk's hand. Row id is insertion order, which matches the series only for the + // first book registered: a later book, or one whose rows were re-registered, allocates from + // the middle and hands out 1006, 1008, 1001 instead of 1001, 1002, 1003. + // + // The cast is guarded because a series need not be plain digits; a prefixed or hyphenated + // number sorts as text after the numeric ones, exactly as the leaflet register displays it + // (see LEAFLET_SERIES_ORDER in SponsorDDReadPlatformServiceImpl). final List> rows = this.jdbcTemplate.queryForList( - "SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE inventory_id = ? AND status = 'AVAILABLE' " - + "ORDER BY id ASC LIMIT ? FOR UPDATE", + "SELECT l.id, l.dd_number FROM m_sponsor_dd_leaflet l WHERE l.inventory_id = ? AND l.status = 'AVAILABLE' " + + "AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request r WHERE r.leaflet_id = l.id AND r.status = 'PENDING') " + + "ORDER BY CASE WHEN l.dd_number ~ '^[0-9]+$' THEN CAST(l.dd_number AS BIGINT) END ASC NULLS LAST, " + + "l.dd_number ASC LIMIT ? FOR UPDATE", inventoryId, count); if (rows.size() < count) { throw new IllegalStateException("Not enough available leaflets in this inventory: need " + count - + ", only " + rows.size() + " available. Register more inventory or reduce the number of drafts."); + + ", only " + rows.size() + " free to allocate (leaflets awaiting approval on another request are not" + + " counted). Register more inventory or reduce the number of drafts."); } return rows; } @@ -517,7 +573,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // gst_percentage, so approving a request never applies a different rate than the maker saw. final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); final Map glMapping = findGlMappingOrThrow(paymentMethod, chargesAmount); - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // Credited to the sponsor bank this leaflet is drawn on, not to a single shared payable — + // the draft is that bank's liability, and its balance is what settlement with them turns on. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflet(leafletId); final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null @@ -740,11 +798,31 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI @Override @Transactional public CommandProcessingResult approveIssueRequestBatch(final Long batchId) { + return approveIssueRequestBatch(batchId, null); + } + + /** + * Approves a slip, optionally only the drafts the customer still wants. + * + * A customer who ordered three drafts and then asks for only two is a counter reality, not an + * exception: the slip is one funding instruction, so the answer is to debit for what is actually + * issued rather than to make the clerk cancel the slip and retype it. Passing null for + * approvedRequestIds approves every line, which is the ordinary case and what the no-argument + * overload does. + * + * The drafts left out are rejected in the same transaction. Their leaflets were never moved off + * AVAILABLE at submission - allocation only reserves the number, and issueDemandDraft is what + * marks it ISSUED - so a dropped draft's number returns to stock for the next customer with + * nothing further to undo, and no gap is punched in the series. + */ + @Override + @Transactional + public CommandProcessingResult approveIssueRequestBatch(final Long batchId, final List approvedRequestIds) { final AppUser currentUser = this.context.authenticatedUser(); validateCheckerRole(currentUser); - final List> requests = findBatchRequestsOrThrow(batchId); - for (final Map request : requests) { + final List> allRequests = findBatchRequestsOrThrow(batchId); + for (final Map request : allRequests) { if (!STATUS_PENDING.equals(request.get("status"))) { throw new IllegalStateException( "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); @@ -755,6 +833,33 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } } + // Which lines are actually being issued. A null selection means all of them; an explicit one + // is validated against this slip so a mistyped id cannot silently approve nothing. + final List> requests; + final List> droppedRequests = new java.util.ArrayList<>(); + if (approvedRequestIds == null) { + requests = allRequests; + } else { + final java.util.Set selected = new java.util.HashSet<>(approvedRequestIds); + final java.util.Set slipIds = allRequests.stream() + .map(r -> ((Number) r.get("id")).longValue()).collect(java.util.stream.Collectors.toSet()); + for (final Long id : selected) { + if (!slipIds.contains(id)) { + throw new IllegalArgumentException("Request #" + id + " is not part of this DD slip."); + } + } + requests = allRequests.stream() + .filter(r -> selected.contains(((Number) r.get("id")).longValue())) + .collect(java.util.stream.Collectors.toList()); + allRequests.stream() + .filter(r -> !selected.contains(((Number) r.get("id")).longValue())) + .forEach(droppedRequests::add); + if (requests.isEmpty()) { + throw new IllegalArgumentException( + "Select at least one draft to approve. To issue none of them, reject the slip instead."); + } + } + final Map first = requests.get(0); final String paymentMethod = (String) first.get("payment_method"); final Long issuingBranchId = ((Number) first.get("issuing_branch_id")).longValue(); @@ -782,7 +887,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, batchId); } - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // The sponsor bank's own payable, for the drafts actually being issued. Checked across all of + // them so a slip somehow spanning two banks is refused rather than crediting both to one. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflets(requests.stream() + .map(r -> ((Number) r.get("leaflet_id")).longValue()).collect(java.util.stream.Collectors.toList())); final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null @@ -792,9 +900,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .map(r -> (String) findLeafletOrThrow(((Number) r.get("leaflet_id")).longValue()).get("dd_number")) .collect(java.util.stream.Collectors.joining(", ")); - // ONE combined withdrawal for the whole slip - face value + charges + GST across every line - - // so the real account balance drops by exactly what the combined GL entry below debits the - // control account for. + // ONE combined withdrawal for the drafts actually being issued - face value + charges + GST + // across each of them - so the real account balance drops by exactly what the combined GL + // entry below debits the control account for. On a partial approval the dropped lines are + // absent from `requests`, so the customer is never debited for a draft they declined. final BigDecimal totalWithdrawal = totalFaceValue.add(totalCharges).add(totalGst); Long resultingSavingsTransactionId = null; Long resultingCurrentTransactionId = null; @@ -819,6 +928,16 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI throw new IllegalStateException( "Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); } + } else if (METHOD_GL.equals(paymentMethod)) { + // The GL account the slip named at submission, not a mapped clearing account: a + // GL-funded slip is debited from the account the clerk chose, exactly as a single + // GL-funded draft is. No withdrawal is executed because a GL account has no passbook - + // the journal entry below IS the movement. + final Object slipGlAccount = first.get("gl_account_id"); + if (slipGlAccount == null) { + throw new IllegalStateException("This GL-funded DD slip has no GL account recorded against it."); + } + drGlAccountId = ((Number) slipGlAccount).longValue(); } else { drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); } @@ -836,12 +955,30 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - this.jdbcTemplate.update( - "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " - + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " - + "WHERE batch_id = ?", - STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, - resultingJournalEntryId, batchId); + // Marked by id rather than by batch_id: on a partial approval the slip's other rows are being + // rejected in the same breath, and "WHERE batch_id" would stamp them APPROVED as well - the + // customer would be issued drafts they had just declined. + for (final Map request : requests) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, ((Number) request.get("id")).longValue()); + } + + // The drafts the customer decided against. Nothing to unwind: they were never issued and the + // debit above covered only the approved lines, so the leaflet simply stays AVAILABLE and goes + // to whoever asks next. The reason is recorded so the register shows why the number was freed + // rather than leaving an unexplained rejection. + for (final Map dropped : droppedRequests) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? " + + "WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, + "Not required by the customer — dropped when the rest of DD slip #" + batchId + " was approved.", + ((Number) dropped.get("id")).longValue()); + } return new CommandProcessingResultBuilder().withEntityId(batchId).build(); } @@ -1034,6 +1171,84 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return rows.isEmpty() ? 1L : ((Number) rows.get(0).get("id")).longValue(); } + /** + * The DD Payable GL account for the sponsor bank a given leaflet is drawn on. + * + * A draft drawn on SBI is owed to SBI and one drawn on HDFC to HDFC, so each bank credits its own + * payable - that per-bank balance is what a settlement with the bank is reconciled against, and a + * single shared account would make it unanswerable. Configured per bank on Organization > Sponsor + * Banks. + * + * Required, not defaulted: a bank with no payable configured has its drafts refused here by name. + * Silently falling back to some other bank's account would credit SBI's liability with money owed + * to HDFC, and nothing downstream would reveal it - the ledger would balance either way. + */ + private Long findDdPayableGlAccountIdForLeaflet(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sb.id AS bank_id, sb.name AS bank_name, sb.dd_payable_gl_account_id " + + "FROM m_sponsor_dd_leaflet l " + + "JOIN m_sponsor_dd_inventory inv ON inv.id = l.inventory_id " + + "LEFT JOIN m_sponsor_bank sb ON sb.id = inv.sponsor_bank_id " + + "WHERE l.id = ?", leafletId); + if (rows.isEmpty() || rows.get(0).get("bank_id") == null) { + throw new IllegalStateException( + "This DD leaflet's inventory is not linked to a sponsor bank, so there is no DD Payable account to credit. " + + "Ask an administrator to check the inventory's sponsor bank in Organization > Sponsor DD Inventory."); + } + final Object payable = rows.get(0).get("dd_payable_gl_account_id"); + if (payable == null) { + throw new IllegalStateException("No DD Payable GL account is configured for sponsor bank '" + + rows.get(0).get("bank_name") + "'. Ask an administrator to set it in Organization > Sponsor Banks" + + " before issuing drafts drawn on this bank."); + } + return ((Number) payable).longValue(); + } + + /** + * The same thing by inventory, for a slip whose leaflets have not been allocated yet. + * + * An inventory is a book of blanks received from one sponsor bank, so the bank - and therefore + * the payable - is known before any leaflet is picked. + */ + private Long findDdPayableGlAccountIdForInventory(final Long inventoryId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sb.id AS bank_id, sb.name AS bank_name, sb.dd_payable_gl_account_id " + + "FROM m_sponsor_dd_inventory inv LEFT JOIN m_sponsor_bank sb ON sb.id = inv.sponsor_bank_id " + + "WHERE inv.id = ?", inventoryId); + if (rows.isEmpty() || rows.get(0).get("bank_id") == null) { + throw new IllegalStateException( + "This DD inventory is not linked to a sponsor bank, so there is no DD Payable account to credit. " + + "Ask an administrator to check it in Organization > Sponsor DD Inventory."); + } + final Object payable = rows.get(0).get("dd_payable_gl_account_id"); + if (payable == null) { + throw new IllegalStateException("No DD Payable GL account is configured for sponsor bank '" + + rows.get(0).get("bank_name") + "'. Ask an administrator to set it in Organization > Sponsor Banks" + + " before issuing drafts drawn on this bank."); + } + return ((Number) payable).longValue(); + } + + /** + * The same account for a whole slip, refusing a slip whose drafts are drawn on different banks. + * + * One slip posts one journal entry, which can credit only one payable. Leaflets are allocated + * from a single inventory and an inventory belongs to one bank, so a mixed slip should not arise + * - but it would silently credit every draft to the first leaflet's bank if it did. + */ + private Long findDdPayableGlAccountIdForLeaflets(final List leafletIds) { + final java.util.Set payables = new java.util.LinkedHashSet<>(); + for (final Long leafletId : leafletIds) { + payables.add(findDdPayableGlAccountIdForLeaflet(leafletId)); + } + if (payables.size() > 1) { + throw new IllegalStateException( + "This DD slip's drafts are drawn on sponsor banks with different DD Payable accounts, so they cannot share one" + + " posting. Raise a separate slip per sponsor bank."); + } + return payables.iterator().next(); + } + private Map findGlMappingOrThrow(final String paymentMethod, final BigDecimal chargesAmount) { final List> rows = this.jdbcTemplate.queryForList( "SELECT dd_payable_gl_account_id, cheque_clearing_gl_account_id, cash_gl_account_id, charges_income_gl_account_id, " @@ -1043,10 +1258,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI "DD GL mapping is not configured. Ask an administrator to configure the Sponsor DD GL accounts in Admin > Sponsor DD GST / GL Settings first."); } final Map mapping = rows.get(0); - if (mapping.get("dd_payable_gl_account_id") == null) { - throw new IllegalStateException( - "No Sponsor DD Payable GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts."); - } + // The payable is deliberately NOT checked here any more: it now belongs to the sponsor bank + // the draft is drawn on (see findDdPayableGlAccountIdForLeaflet), and this global row keeps + // only what is the same for every sponsor — this bank's own charges income, GST, cash and + // cheque-clearing accounts, the GST rate and the DD limit. The column is left in place for + // the banks seeded from it on upgrade. if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) { throw new IllegalStateException( "No cheque-clearing GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cheque payments."); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 108e7421a..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 0ad5ba659..545b6450b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -114,4 +114,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml new file mode 100644 index 000000000..72a8fc279 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml @@ -0,0 +1,49 @@ + + + + + + + + UPDATE m_aml_alert + SET status = 'RAISED', + reviewed_by = NULL, + reviewed_on = NULL + WHERE status = 'UNDER_REVIEW' + AND disposition IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml new file mode 100644 index 000000000..661c58b49 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_sponsor_bank + SET dd_payable_gl_account_id = ( + SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping + WHERE dd_payable_gl_account_id IS NOT NULL LIMIT 1 + ) + WHERE dd_payable_gl_account_id IS NULL; + + + +