From 4b9093007fde65c1e833f7ec39c6cd4aeba0931b Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 24 Sep 2026 18:05:58 +0530 Subject: [PATCH 1/3] feat: update TDS config, RBI reserve, current products, OSS returns, and dormancy services --- ...ToGLAccountMappingReadPlatformService.java | 2 + ...AccountMappingReadPlatformServiceImpl.java | 14 + .../portfolio/PortfolioProductType.java | 5 +- .../savings/SavingsApiConstants.java | 9 + .../savings/data/SavingsProductData.java | 10 + .../currentaccount/domain/CurrentAccount.java | 3 +- .../domain/CurrentAccountTransaction.java | 16 +- ...ccountTransactionWritePlatformService.java | 6 + .../data/CurrentProductDataValidator.java | 128 +- .../currentproduct/domain/CurrentProduct.java | 68 + .../domain/CurrentProductAssembler.java | 22 + .../CurrentProductGlMappingWriter.java | 115 + ...CurrentProductReadPlatformServiceImpl.java | 8 + ...WritePlatformServiceJpaRepositoryImpl.java | 12 +- .../starter/CurrentProductConfiguration.java | 5 +- .../service/AccountingProcessorHelper.java | 3276 +++++++++-------- .../service/AccountBookDebitService.java | 152 + ...ountTransfersWritePlatformServiceImpl.java | 47 +- .../account/starter/AccountConfiguration.java | 281 +- .../ChequeBookIssuanceChargeService.java | 2 +- .../ClearingGlAccountResolverService.java | 2 +- .../ClerkDisbursementRequestApiResource.java | 133 +- .../client/service/KycRestrictionGuard.java | 57 +- .../ossreturn/api/OssReturnApiResource.java | 850 +++++ .../api/RbiReserveConfigApiResource.java | 687 ++++ .../api/RbiReserveReturnApiResource.java | 892 +++++ .../api/CurrentProductsApiResource.java | 14 +- .../savings/data/DormantAccountData.java | 2 + .../UpdateSavingsDormantAccountsTasklet.java | 5 + .../service/CurrentAccountCashGlResolver.java | 53 + .../CurrentAccountDormancyJobService.java | 95 + ...CurrentAccountReadPlatformServiceImpl.java | 82 +- ...ntTransactionWritePlatformServiceImpl.java | 125 +- ...urrentAccountWritePlatformServiceImpl.java | 1547 ++++---- .../savings/service/DormancyClock.java | 27 +- ...DormantAccountReadPlatformServiceImpl.java | 38 +- ...DIssueRequestWritePlatformServiceImpl.java | 53 +- .../tds/api/TdsConfigApiResource.java | 383 ++ .../tenant/final-changelog-tenant.xml | 13 + .../parts/0571_create_rbi_reserve_config.xml | 213 ++ .../0572_create_oss_return_permissions.xml | 80 + ...not_null_on_sponsor_dd_mapping_payable.xml | 47 + .../parts/0574_rbi_reserve_ndtl_weighting.xml | 180 + .../parts/0575_rbi_reserve_custom_roles.xml | 105 + .../0576_widen_rbi_reserve_rate_type.xml | 47 + ...7_add_current_product_od_scheme_fields.xml | 71 + .../0578_grant_cashier_read_codevalue.xml | 50 + ...t_product_gl_mappings_own_product_type.xml | 82 + .../0580_unique_core_product_gl_mapping.xml | 62 + .../tenant/parts/0581_form_a_completion.xml | 386 ++ .../tenant/parts/0582_create_tds_config.xml | 252 ++ ...SavingsProductAccountingDataValidator.java | 9 +- .../savings/domain/CurrentOnlyEditFields.java | 16 + .../domain/ProductPendingEditFieldMapper.java | 25 + .../savings/parts/module-changelog-master.xml | 1 + ...rent_product_od_scheme_pending_columns.xml | 58 + 56 files changed, 8174 insertions(+), 2749 deletions(-) create mode 100644 fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java index 1cfba6559..365ffe441 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java @@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService { Map fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType); + Map fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType); + List fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId); List fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId); 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 8c6ed8881..65b754870 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 @@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product return accountMappingDetails; } + /** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */ + @Override + public Map fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) { + final List mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId, + PortfolioProductType.CURRENT.getValue()); + if (AccountingValidations.isCashBasedAccounting(accountingType)) { + return setCashSavingsProductToGLAccountMaps(mappings); + } + if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) { + return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings); + } + return null; + } + @Override public List fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) { return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java index 7b2bf1c4f..fc6f313f3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java @@ -24,7 +24,10 @@ public enum PortfolioProductType { SAVING(2, "productType.saving"), // CLIENT(5, "productType.client"), // PROVISIONING(3, "productType.provisioning"), // - SHARES(4, "productType.shares"); // + SHARES(4, "productType.shares"), // + // Current products map their GLs under their own type: sharing SAVING with savings products let a savings + // product and a current product with the same id read each other's GL rows. + CURRENT(6, "productType.current"); // private final Integer value; private final String code; 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 f47a04cf5..0dd86849f 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 @@ -286,6 +286,15 @@ public class SavingsApiConstants { public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod"; public static final String validForPeriodParamName = "validForPeriod"; public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing"; + // Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an + // amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either. + public static final String odMinLimitParamName = "odMinLimit"; + public static final String odSecurityTypeParamName = "odSecurityType"; + // Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the + // limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess. + public static final String odExcessAllowedParamName = "odExcessAllowed"; + public static final String odExcessLimitPercentParamName = "odExcessLimitPercent"; + public static final String odPenalRateParamName = "odPenalRate"; // Status of Account fields public static final String accountStatusParamName = "accountStatus"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java index 0c74cde3c..f508d3227 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java @@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable { @Getter @Setter private String avgBalanceCalcPeriod; @Getter @Setter private String validForPeriod; @Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing; + @Getter @Setter private BigDecimal odMinLimit; + @Getter @Setter private String odSecurityType; + @Getter @Setter private Boolean odExcessAllowed; + @Getter @Setter private BigDecimal odExcessLimitPercent; + @Getter @Setter private BigDecimal odPenalRate; // Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects @Getter @Setter private Map accountFacilityConfig; @@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable { target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod()); target.setValidForPeriod(source.getValidForPeriod()); target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing()); + target.setOdMinLimit(source.getOdMinLimit()); + target.setOdSecurityType(source.getOdSecurityType()); + target.setOdExcessAllowed(source.getOdExcessAllowed()); + target.setOdExcessLimitPercent(source.getOdExcessLimitPercent()); + target.setOdPenalRate(source.getOdPenalRate()); target.setOtherGlMappingsMap(source.getOtherGlMappingsMap()); target.setAccountFacilityConfig(source.getAccountFacilityConfig()); target.setAccountFacilityLimits(source.getAccountFacilityLimits()); diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java index 38bf40627..2a72e7916 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java @@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom { @Column(name = "status_enum", nullable = false) private Integer statusEnum = 100; - // SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit + // SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit, + // 600=BlockDebit @Column(name = "sub_status_enum", nullable = false) private Integer subStatusEnum = 0; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java index 60f4eb073..bf9c21709 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java @@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { @JoinColumn(name = "office_id", nullable = false) private Office office; - // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal + // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat @Column(name = "transaction_type_enum", nullable = false) private Integer transactionTypeEnum; @@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { return txn; } + // transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings + public static CurrentAccountTransaction escheat(CurrentAccount account, Office office, + BigDecimal amount, LocalDate transactionDate) { + CurrentAccountTransaction txn = new CurrentAccountTransaction(); + txn.currentAccount = account; + txn.office = office; + txn.transactionTypeEnum = 19; + txn.amount = amount; + txn.transactionDate = transactionDate; + txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability"; + txn.submittedOnDate = transactionDate; + return txn; + } + // transactionTypeEnum = 7 (Reversal / Undo) public static CurrentAccountTransaction reversal(CurrentAccount account, Office office, BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) { diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java index d860b7fb0..2533688f7 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java @@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService { CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command); CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command); + + /** + * Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the + * nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done. + */ + boolean escheat(Long accountId); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java index 8d09d6b64..859841b83 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java @@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; @@ -136,7 +142,17 @@ public class CurrentProductDataValidator { SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName, SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName, SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName, - SavingsApiConstants.glAccountIdParamName)); + odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName, + odPenalRateParamName, SavingsApiConstants.glAccountIdParamName)); + + /** How long an OD sanction on this scheme runs before it has to be renewed. */ + private static final List OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y"); + /** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */ + private static final List OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH"); + /** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */ + private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50"); + /** Excess drawing is a small tolerance over the limit, never a second limit. */ + private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25"); public void validateForCreate(final String json) { if (StringUtils.isBlank(json)) { @@ -260,10 +276,10 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true); } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, null); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -358,7 +374,7 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false); } if (null != isDormancyActive && isDormancyActive) { @@ -380,7 +396,7 @@ public class CurrentProductDataValidator { } } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, product); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -398,23 +414,103 @@ public class CurrentProductDataValidator { } } - private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + /** + * The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is + * allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate, + * a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly, + * as RBI requires for advances, or at most quarterly. + * + * On update, product is the stored product and fills in any value the request leaves out, so a partial edit is + * checked against the scheme it will produce; on create it is null. + */ + private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element, + final CurrentProduct product) { if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) { final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element); baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue(); } - if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) { - final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount(); + final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element)) + : product != null && product.isAllowOverdraft(); + + final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName, + product == null ? null : product.getOverdraftLimit()); + final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit()); + final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName, + product == null ? null : product.getNominalAnnualInterestRateOverdraft()); + final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName, + product == null ? null : product.getMinOverdraftForInterestCalculation()); + + if (!allowOverdraft) { + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull() + .zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + return; } - if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) { - final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element); - baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount(); + + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount(); + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount(); + if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) { + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit) + .failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit."); } - if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) { - final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element); - baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + + final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod()); + baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank() + .isOneOfTheseStringValues(OD_VALIDITY_PERIODS); + + final String securityType = stringOrStored(element, odSecurityTypeParamName, + product == null ? null : product.getOdSecurityType()); + baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank() + .isOneOfTheseStringValues(OD_SECURITY_TYPES); + + // Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the + // excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate + // charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance. + final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element)) + : product != null && product.isOdExcessAllowed(); + if (excessAllowed) { + final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName, + product == null ? null : product.getOdExcessLimitPercent()); + baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount() + .notGreaterThanMax(MAX_EXCESS_PERCENT); + final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName, + product == null ? null : product.getOdPenalRate()); + baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); } + + final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element) + : product == null ? null : product.getInterestPostingPeriodType(); + baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull() + .isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(), + SavingsPostingInterestPeriodType.QUATERLY.getValue()); + + final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element) + : product == null ? null : product.getInterestCalculationDaysInYearType(); + baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull() + .isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(), + SavingsInterestCalculationDaysInYearType.DAYS_365.getValue()); + } + + private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) + ? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element) + : stored; + } + + private String stringOrStored(final JsonElement element, final String paramName, final String stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element) + : stored; } private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { @@ -430,7 +526,7 @@ public class CurrentProductDataValidator { && BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) { final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element); final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { + if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit"); } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java index 947885750..4a632868f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java @@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import com.google.gson.JsonArray; import jakarta.persistence.CascadeType; @@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom { @Column(name = "od_account_closed_zero_balance_in_day_closing") private boolean odAccountClosedZeroBalanceInDayClosing = false; + // The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest. + @Column(name = "od_min_limit", scale = 6, precision = 19) + private BigDecimal odMinLimit; + + // CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...). + @Column(name = "od_security_type", length = 20) + private String odSecurityType; + + // Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess + // as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone. + @Column(name = "od_excess_allowed") + private boolean odExcessAllowed = false; + + @Column(name = "od_excess_limit_percent", scale = 6, precision = 19) + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate", scale = 6, precision = 19) + private BigDecimal odPenalRate; + @OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true) private CurrentProductFacilityConfig facilityConfig; @@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom { actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue); this.odAccountClosedZeroBalanceInDayClosing = newValue; } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName); + actualChanges.put(odMinLimitParamName, newValue); + this.odMinLimit = newValue; + } + if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) { + final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName); + actualChanges.put(odSecurityTypeParamName, newValue); + this.odSecurityType = newValue; + } + if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + actualChanges.put(odExcessAllowedParamName, newValue); + this.odExcessAllowed = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName); + actualChanges.put(odExcessLimitPercentParamName, newValue); + this.odExcessLimitPercent = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName); + actualChanges.put(odPenalRateParamName, newValue); + this.odPenalRate = newValue; + } + if (!this.odExcessAllowed) { + this.odExcessLimitPercent = null; + this.odPenalRate = null; + } // Clear OD-specific fields when overdraft is disabled if (!this.allowOverdraft) { this.avgBalanceCalcPeriod = null; this.validForPeriod = null; this.odAccountClosedZeroBalanceInDayClosing = false; + this.odMinLimit = null; + this.odSecurityType = null; + this.odExcessAllowed = false; + this.odExcessLimitPercent = null; + this.odPenalRate = null; } return actualChanges; @@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom { public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; } public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; } public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; } + public BigDecimal getOdMinLimit() { return odMinLimit; } + public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; } + public String getOdSecurityType() { return odSecurityType; } + public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; } + public boolean isOdExcessAllowed() { return odExcessAllowed; } + public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; } + public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; } + public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; } + public BigDecimal getOdPenalRate() { return odPenalRate; } + public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; } } 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 4bdecc85c..29ed7c5ee 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 @@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName; @@ -314,6 +319,23 @@ public class CurrentProductAssembler { if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) { product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName)); } + if (command.parameterExists(odMinLimitParamName)) { + product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName)); + } + if (command.parameterExists(odSecurityTypeParamName)) { + product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName)); + } + final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName) + && command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + product.setOdExcessAllowed(excessAllowed); + if (excessAllowed) { + if (command.parameterExists(odExcessLimitPercentParamName)) { + product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName)); + } + if (command.parameterExists(odPenalRateParamName)) { + product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName)); + } + } } return product; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java new file mode 100644 index 000000000..b41d3abcc --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.currentproduct.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}. + * + *

+ * They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product + * and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the + * account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type, + * the two can never meet. + * + *

+ * A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a + * partial edit changes only what it names. Accounting rule NONE clears every mapping. + */ +@Component +@RequiredArgsConstructor +public class CurrentProductGlMappingWriter { + + /** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */ + private static final Map PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>(); + + static { + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18); + } + + private static final int CURRENT = PortfolioProductType.CURRENT.getValue(); + + private final JdbcTemplate jdbcTemplate; + + /** + * Applies the request's mappings to the product and returns what changed, keyed by parameter name. + * + * @param accountingRule + * the product's accounting rule after this request (1 = NONE) + */ + public Map write(final Long productId, final JsonCommand command, final Integer accountingRule) { + final Map changes = new LinkedHashMap<>(); + if (accountingRule == null || accountingRule == 1) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + return changes; + } + final JsonElement parsed = command.parsedJson(); + final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + for (final Map.Entry entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) { + final String param = entry.getKey(); + if (!json.has(param)) { + continue; + } + final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong(); + final Long existing = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", + Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null); + if (java.util.Objects.equals(existing, glAccountId)) { + continue; + } + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue()); + if (glAccountId != null) { + jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)" + + " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue()); + } + changes.put(param, glAccountId); + } + return changes; + } + + /** Removes every mapping of a deleted product. */ + public void deleteAll(final Long productId) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java index e0f22d7e5..befebaa50 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java @@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, "); sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, "); sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, "); + sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, "); + sqlBuilder.append("cp.od_penal_rate as odPenalRate, "); sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id " + "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval "); @@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod")); productData.setValidForPeriod(rs.getString("validForPeriod")); productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing")); + productData.setOdMinLimit(rs.getBigDecimal("odMinLimit")); + productData.setOdSecurityType(rs.getString("odSecurityType")); + productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed")); + productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent")); + productData.setOdPenalRate(rs.getBigDecimal("odPenalRate")); productData.setIsPendingApproval(rs.getBoolean("isPendingApproval")); return productData; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java index 7ef44cd2c..02b747fb2 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java @@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final FromJsonHelper fromApiJsonHelper; private final CommandSourceRepository commandSourceRepository; + private final CurrentProductGlMappingWriter glMappingWriter; private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) { String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME; @@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr this.depositLimitKycRepository.saveAndFlush(depositLimitKyc); } - this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command, - DepositAccountType.SAVINGS_DEPOSIT); + // Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter. + this.glMappingWriter.write(product.getId(), command, product.getAccountingType()); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // @@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr } // accounting related changes - final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName); - final Map accountingMappingChanges = this.accountMappingWritePlatformService - .updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(), - DepositAccountType.SAVINGS_DEPOSIT); + final Map accountingMappingChanges = this.glMappingWriter.write(product.getId(), command, + product.getAccountingType()); changes.putAll(accountingMappingChanges); if (!changes.isEmpty()) { @@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr .orElseThrow(() -> new SavingsProductNotFoundException(productId)); this.currentProductRepository.delete(product); + this.glMappingWriter.deleteAll(productId); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java index 8b7af3502..4a59f90d2 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java @@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService; @@ -78,11 +79,11 @@ public class CurrentProductConfiguration { ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, FromJsonHelper fromApiJsonHelper, - CommandSourceRepository commandSourceRepository) { + CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) { return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator, currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository, currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, - pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository); + pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter); } } 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 2c6e8888c..f8235a2a9 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 @@ -1,1618 +1,1658 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -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; -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; -import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; -import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; -import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.LoanDTO; -import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SharesDTO; -import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; -import org.apache.fineract.infrastructure.core.data.EnumOptionData; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; -import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.account.PortfolioAccountType; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; -import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; -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 { - - public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; - public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; - public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; - public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; - public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; - 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) { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); - } - - public static void clearDepositAccountTypeContext() { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); - } - - private String getSavingsTransactionIdentifier() { - return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { - case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; - case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; - default -> SAVINGS_TRANSACTION_IDENTIFIER; - }; - } - - private final JournalEntryRepository glJournalEntryRepository; - private final ProductToGLAccountMappingRepository accountMappingRepository; - private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; - private final GLClosureRepository closureRepository; - private final GLAccountRepository glAccountRepository; - private final OfficeRepository officeRepository; - 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) { - final Long loanId = accountingBridgeData.getLoanId(); - final Long loanProductId = accountingBridgeData.getLoanProductId(); - final Long officeId = accountingBridgeData.getOfficeId(); - final String currencyCode = accountingBridgeData.getCurrencyCode(); - final List newLoanTransactions = new ArrayList<>(); - boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); - boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); - boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); - final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); - final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); - final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); - final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); - final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); - final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); - - final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); - - for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { - final Long transactionOfficeId = loanTxnDto.getOfficeId(); - final String transactionId = loanTxnDto.getId().toString(); - final LocalDate transactionDate = loanTxnDto.getDate(); - final LoanTransactionEnumData transactionType = loanTxnDto.getType(); - final BigDecimal amount = loanTxnDto.getAmount(); - final BigDecimal principal = loanTxnDto.getPrincipalPortion(); - final BigDecimal interest = loanTxnDto.getInterestPortion(); - final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); - final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); - final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); - final boolean reversed = loanTxnDto.isReversed(); - final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); - final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); - final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); - - final List feePaymentDetails = new ArrayList<>(); - final List penaltyPaymentDetails = new ArrayList<>(); - // extract charge payment details (if exists) - if (loanTxnDto.getLoanChargesPaid() != null) { - List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); - for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { - final Long chargeId = loanChargePaid.getChargeId(); - final Long loanChargeId = loanChargePaid.getLoanChargeId(); - final boolean isPenalty = loanChargePaid.getIsPenalty(); - final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPaymentDetails.add(chargePaymentDTO); - } else { - feePaymentDetails.add(chargePaymentDTO); - } - } - } - - boolean localIsAccountTransfer = isAccountTransfer; - if (!localIsAccountTransfer) { - localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.LOAN); - } - - BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); - BigDecimal feePaid = loanTxnDto.getFeePaid(); - BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); - - final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, - penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, - feePaid, penaltyPaid); - - transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); - newLoanTransactions.add(transaction); - } - - return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, - periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, - chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, - accountingBridgeData.getBuydownFeeClassificationCodeValue(), - accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); - } - - public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long chargeOffReasonId) { - return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); - } - - public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long writeOffReasonId) { - return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); - } - - public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - final Long classificationId, final String classificationType) { - if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { - return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); - } else { - return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), - classificationId); - } - } - - public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long loanId = (Long) accountingBridgeData.get("savingsId"); - final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") - ? (Integer) accountingBridgeData.get("depositAccountType") - : 100; - final List newSavingsTransactions = new ArrayList<>(); - boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final boolean reversed = (Boolean) map.get("reversed"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); - - final List feePayments = new ArrayList<>(); - final List penaltyPayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("savingsChargesPaid")) { - @SuppressWarnings("unchecked") - final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); - for (final Map loanChargePaid : savingsChargesPaidData) { - final Long chargeId = (Long) loanChargePaid.get("chargeId"); - final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); - final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPayments.add(chargePaymentDTO); - } else { - feePayments.add(chargePaymentDTO); - } - } - } - - final List taxPayments = new ArrayList<>(); - if (map.containsKey("taxDetails")) { - @SuppressWarnings("unchecked") - final List> taxDataList = (List>) map.get("taxDetails"); - for (final Map taxData : taxDataList) { - final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); - final Long creditAccountId = (Long) taxData.get("creditAccountId"); - final Long debitAccountId = (Long) taxData.get("debitAccountId"); - taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); - } - } - - if (!isAccountTransfer) { - isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.SAVINGS); - } - final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, - taxPayments); - - newSavingsTransactions.add(transaction); - - } - - return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, - newSavingsTransactions, depositAccountType); - } - - public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); - final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final List newTransactions = new ArrayList<>(); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); - final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - - final List feePayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("chargesPaid")) { - @SuppressWarnings("unchecked") - final List> chargesPaidData = (List>) map.get("chargesPaid"); - for (final Map chargePaid : chargesPaidData) { - final Long chargeId = (Long) chargePaid.get("chargeId"); - final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); - final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - feePayments.add(chargePaymentDTO); - } - } - final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); - - newTransactions.add(transaction); - - } - - return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, - accrualBasedAccountingEnabled, newTransactions); - } - - public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { - - final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); - final Long clientId = (Long) accountingBridgeData.get("clientId"); - final Long transactionId = (Long) accountingBridgeData.get("id"); - final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); - final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); - final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); - final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); - final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); - - final List clientChargePaymentDTOs = new ArrayList<>(); - // extract client charge payment details (if exists) - if (accountingBridgeData.containsKey("clientChargesPaid")) { - @SuppressWarnings("unchecked") - final List> clientChargesPaidData = (List>) accountingBridgeData - .get("clientChargesPaid"); - for (final Map clientChargePaid : clientChargesPaidData) { - final Long chargeId = (Long) clientChargePaid.get("chargeId"); - final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); - final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); - final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); - final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, - isPenalty, incomeAccountId); - clientChargePaymentDTOs.add(clientChargePaymentDTO); - } - } - - return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, - currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); - - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * loanProductId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - final Map creditDetailsMap = new LinkedHashMap<>(); - final Map debitDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, - chargeId); - final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); - final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // aggregate amounts by account for credit entries - creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); - - // aggregate amounts by account for debit entries - debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - BigDecimal totalDebitedAmount = BigDecimal.ZERO; - - // Create credit journal entries - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - // Create debit journal entries using charge-specific debit accounts - for (final Map.Entry entry : debitDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalDebitedAmount = totalDebitedAmount.add(amount); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", - "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalCreditedAmount, totalAmount); - } - - if (totalAmount.compareTo(totalDebitedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", - "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalDebitedAmount, totalAmount); - } - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - */ - public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - // reverse debits and credits for reversals - if (isReversal) { - accountTypeToDebitId = accountTypeToBeCredited; - accountTypeToCreditId = accountTypeToBeDebited; - } - createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, - loanId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - */ - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, - JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, - LocalDate transactionDate) { - splitAccountsHolder.forEach(journalItemHolder -> { - if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), - paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, - journalItemHolder.getAmount()); - } - }); - if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { - final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), - paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, - totalAccountHolder.getAmount()); - } - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - /** - * @param latestGLClosure - * @param transactionDate - */ - public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { - // check if an accounting closure has happened for this branch after the transaction Date - if (latestGLClosure != null) { - if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { - throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), - null, null); - } - } - } - - public GLClosure getLatestClosureByBranch(final long officeId) { - return this.closureRepository.getLatestGLClosureByBranch(officeId); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - 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 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(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); - - // 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: into the member's own balance. - final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; - postSavingsLeg(office, currencyCode, payoutFromAccount, 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. - * - * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited - * @param debitEntityId entity (account) ID shown on the debit entry - * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited - * @param creditEntityId entity (account) ID shown on the credit entry - */ - public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, - final Long debitProductId, final Long debitEntityId, - final Long creditProductId, final Long creditEntityId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param savingsId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - * @param taxDetails - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), - savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); - } - } - } - createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, - paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, - taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } - } - } - createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, true); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, false); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * savingsProductId - * @param paymentTypeId - * paymentTypeId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param isReversal - * isReversal - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, - final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public Office getOfficeById(final long officeId) { - return this.officeRepository.getReferenceById(officeId); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) - throws DataAccessException { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, - GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, - String currencyCode, GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, - shareAccountId, transactionId, transactionDate, totalAmount); - createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, - transactionId, transactionDate, totalAmount, chargePaymentDTOs); - } - - public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, - AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); - - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Check for charge-specific mappings for all account types (not just income accounts) - // This allows charge-specific GL account mappings for debit accounts as well - if (chargeId != null) { - final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificAccountMapping != null) { - accountMapping = chargeSpecificAccountMapping; - } - } - return accountMapping.getGlAccount(); - } - - private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, - final Long chargeId) { - - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Vishwas TODO: remove this condition as it should always be true - - if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() - || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { - GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); - if (glAccount != null) { - return glAccount; - } - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - - accountMapping = chargeSpecificIncomeAccountMapping; - } - } - - 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; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, - CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - - if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - 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; - } - - private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - accountMapping = chargeSpecificIncomeAccountMapping; - } - return accountMapping.getGlAccount(); - } - - private boolean isOrganizationAccount(final int accountMappingTypeId) { - return FinancialActivity.fromInt(accountMappingTypeId) != null; - } - - public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, - final List clientChargePaymentDTOs) { - /*** - * Map to track each account affected and the net credit to be made for a particular account - ***/ - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { - if (clientChargePaymentDTO.getIncomeAccountId() != null) { - final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); - BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in - // the map - if (creditDetailsMap.containsKey(accountId)) { - final BigDecimal existingAmount = creditDetailsMap.get(accountId); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(accountId, chargeSpecificAmount); - } - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = getGLAccountById(entry.getKey()); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - if (isReversal) { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - return totalCreditedAmount; - } - - public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, - final Boolean isReversal) { - final GLAccount account = financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); - if (isReversal) { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - - private GLAccount getGLAccountById(final Long accountId) { - return this.glAccountRepository.getReferenceById(accountId); - } - - public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { - if (chargeRefundChargeType == null - || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { - String errorValue; - errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); - throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", - "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); - } - Integer incomeAccount; - if (chargeRefundChargeType.equalsIgnoreCase("P")) { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); - } else { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); - - } - return incomeAccount; - } - - public JournalEntry persistJournalEntry(JournalEntry journalEntry) { - boolean isNew = journalEntry.isNew(); - JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); - if (isNew && journalEntry.getLoanTransactionId() != null) { - businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); - } - return savedJournalEntry; - } - - private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { - final Map creditDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - BigDecimal amount = chargePaymentDTO.getAmount(); - - creditDetailsMap.merge(account, amount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - - for (Map.Entry entry : creditDetailsMap.entrySet()) { - GLAccount account = entry.getKey(); - BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - - if (isCredit) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - totalCreditedAmount, totalAmount); - } - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +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; +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; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.LoanDTO; +import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SharesDTO; +import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.account.PortfolioAccountType; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; +import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; +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 { + + public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; + public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; + public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; + public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; + public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; + 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) { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); + } + + public static void clearDepositAccountTypeContext() { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); + } + + private String getSavingsTransactionIdentifier() { + return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { + case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; + case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; + default -> SAVINGS_TRANSACTION_IDENTIFIER; + }; + } + + private final JournalEntryRepository glJournalEntryRepository; + private final ProductToGLAccountMappingRepository accountMappingRepository; + private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; + private final GLClosureRepository closureRepository; + private final GLAccountRepository glAccountRepository; + private final OfficeRepository officeRepository; + 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) { + final Long loanId = accountingBridgeData.getLoanId(); + final Long loanProductId = accountingBridgeData.getLoanProductId(); + final Long officeId = accountingBridgeData.getOfficeId(); + final String currencyCode = accountingBridgeData.getCurrencyCode(); + final List newLoanTransactions = new ArrayList<>(); + boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); + boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); + boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); + final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); + final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); + final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); + final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); + final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); + final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); + + final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); + + for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { + final Long transactionOfficeId = loanTxnDto.getOfficeId(); + final String transactionId = loanTxnDto.getId().toString(); + final LocalDate transactionDate = loanTxnDto.getDate(); + final LoanTransactionEnumData transactionType = loanTxnDto.getType(); + final BigDecimal amount = loanTxnDto.getAmount(); + final BigDecimal principal = loanTxnDto.getPrincipalPortion(); + final BigDecimal interest = loanTxnDto.getInterestPortion(); + final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); + final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); + final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); + final boolean reversed = loanTxnDto.isReversed(); + final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); + final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); + final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); + + final List feePaymentDetails = new ArrayList<>(); + final List penaltyPaymentDetails = new ArrayList<>(); + // extract charge payment details (if exists) + if (loanTxnDto.getLoanChargesPaid() != null) { + List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); + for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { + final Long chargeId = loanChargePaid.getChargeId(); + final Long loanChargeId = loanChargePaid.getLoanChargeId(); + final boolean isPenalty = loanChargePaid.getIsPenalty(); + final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPaymentDetails.add(chargePaymentDTO); + } else { + feePaymentDetails.add(chargePaymentDTO); + } + } + } + + boolean localIsAccountTransfer = isAccountTransfer; + if (!localIsAccountTransfer) { + localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.LOAN); + } + + BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); + BigDecimal feePaid = loanTxnDto.getFeePaid(); + BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); + + final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, + penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, + feePaid, penaltyPaid); + + transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); + newLoanTransactions.add(transaction); + } + + return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, + periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, + chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, + accountingBridgeData.getBuydownFeeClassificationCodeValue(), + accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); + } + + public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long chargeOffReasonId) { + return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); + } + + public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long writeOffReasonId) { + return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); + } + + public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + final Long classificationId, final String classificationType) { + if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { + return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); + } else { + return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), + classificationId); + } + } + + public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long loanId = (Long) accountingBridgeData.get("savingsId"); + final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") + ? (Integer) accountingBridgeData.get("depositAccountType") + : 100; + final List newSavingsTransactions = new ArrayList<>(); + boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final boolean reversed = (Boolean) map.get("reversed"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); + + final List feePayments = new ArrayList<>(); + final List penaltyPayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("savingsChargesPaid")) { + @SuppressWarnings("unchecked") + final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); + for (final Map loanChargePaid : savingsChargesPaidData) { + final Long chargeId = (Long) loanChargePaid.get("chargeId"); + final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); + final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPayments.add(chargePaymentDTO); + } else { + feePayments.add(chargePaymentDTO); + } + } + } + + final List taxPayments = new ArrayList<>(); + if (map.containsKey("taxDetails")) { + @SuppressWarnings("unchecked") + final List> taxDataList = (List>) map.get("taxDetails"); + for (final Map taxData : taxDataList) { + final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); + final Long creditAccountId = (Long) taxData.get("creditAccountId"); + final Long debitAccountId = (Long) taxData.get("debitAccountId"); + taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); + } + } + + if (!isAccountTransfer) { + isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.SAVINGS); + } + final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, + taxPayments); + + newSavingsTransactions.add(transaction); + + } + + return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, + newSavingsTransactions, depositAccountType); + } + + public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); + final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final List newTransactions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); + final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + + final List feePayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("chargesPaid")) { + @SuppressWarnings("unchecked") + final List> chargesPaidData = (List>) map.get("chargesPaid"); + for (final Map chargePaid : chargesPaidData) { + final Long chargeId = (Long) chargePaid.get("chargeId"); + final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); + final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + feePayments.add(chargePaymentDTO); + } + } + final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); + + newTransactions.add(transaction); + + } + + return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, + accrualBasedAccountingEnabled, newTransactions); + } + + public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { + + final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); + final Long clientId = (Long) accountingBridgeData.get("clientId"); + final Long transactionId = (Long) accountingBridgeData.get("id"); + final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); + final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); + final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); + final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); + final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); + + final List clientChargePaymentDTOs = new ArrayList<>(); + // extract client charge payment details (if exists) + if (accountingBridgeData.containsKey("clientChargesPaid")) { + @SuppressWarnings("unchecked") + final List> clientChargesPaidData = (List>) accountingBridgeData + .get("clientChargesPaid"); + for (final Map clientChargePaid : clientChargesPaidData) { + final Long chargeId = (Long) clientChargePaid.get("chargeId"); + final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); + final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); + final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); + final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, + isPenalty, incomeAccountId); + clientChargePaymentDTOs.add(clientChargePaymentDTO); + } + } + + return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, + currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); + + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * loanProductId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + final Map creditDetailsMap = new LinkedHashMap<>(); + final Map debitDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, + chargeId); + final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); + final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // aggregate amounts by account for credit entries + creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); + + // aggregate amounts by account for debit entries + debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + BigDecimal totalDebitedAmount = BigDecimal.ZERO; + + // Create credit journal entries + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + // Create debit journal entries using charge-specific debit accounts + for (final Map.Entry entry : debitDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalDebitedAmount = totalDebitedAmount.add(amount); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", + "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalCreditedAmount, totalAmount); + } + + if (totalAmount.compareTo(totalDebitedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", + "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalDebitedAmount, totalAmount); + } + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + */ + public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + // reverse debits and credits for reversals + if (isReversal) { + accountTypeToDebitId = accountTypeToBeCredited; + accountTypeToCreditId = accountTypeToBeDebited; + } + createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, + loanId, transactionId, transactionDate, amount); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + */ + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, + JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, + LocalDate transactionDate) { + splitAccountsHolder.forEach(journalItemHolder -> { + if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), + paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, + journalItemHolder.getAmount()); + } + }); + if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { + final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), + paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, + totalAccountHolder.getAmount()); + } + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + /** + * @param latestGLClosure + * @param transactionDate + */ + public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { + // check if an accounting closure has happened for this branch after the transaction Date + if (latestGLClosure != null) { + if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), + null, null); + } + } + } + + public GLClosure getLatestClosureByBranch(final long officeId) { + return this.closureRepository.getLatestGLClosureByBranch(officeId); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + 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 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(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); + + // 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: into the member's own balance. + final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; + postSavingsLeg(office, currencyCode, payoutFromAccount, 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. + * + * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited + * @param debitEntityId entity (account) ID shown on the debit entry + * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited + * @param creditEntityId entity (account) ID shown on the credit entry + */ + public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, + final Long debitProductId, final Long debitEntityId, + final Long creditProductId, final Long creditEntityId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** + * The same direct transfer where either side may be a current account. A current product maps its GLs under + * {@link PortfolioProductType#CURRENT}, so each side's control GL is read under its own product type -- reading a + * current product under SAVING picked up the GLs of the savings product that happens to share its id. + */ + public void createDirectTransferJournalEntries(final Office office, final String currencyCode, final Long debitProductId, + final boolean debitIsCurrent, final Long debitEntityId, final Long creditProductId, final boolean creditIsCurrent, + final Long creditEntityId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = controlGlAccount(debitProductId, debitIsCurrent); + final GLAccount creditAccount = controlGlAccount(creditProductId, creditIsCurrent); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** Dr a current product's Current Control, Cr the GL mapped to {@code financialActivity}. */ + public void createCurrentControlToFinancialActivityJournalEntries(final Office office, final String currencyCode, + final Long currentProductId, final int financialActivity, final Long currentAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount controlAccount = controlGlAccount(currentProductId, true); + final GLAccount activityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(financialActivity).getGlAccount(); + createDebitJournalEntryForSavings(office, currencyCode, controlAccount, currentAccountId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, activityAccount, currentAccountId, transactionId, transactionDate, amount); + } + + private GLAccount controlGlAccount(final Long productId, final boolean isCurrent) { + if (!isCurrent) { + return getLinkedGLAccountForSavingsProduct(productId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + } + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(productId, + PortfolioProductType.CURRENT.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (mapping == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.product.control.gl.not.mapped", + "Current product " + productId + " has no Current Control GL mapped; map it on the product's Accounting tab.", + productId); + } + return mapping.getGlAccount(); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param savingsId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + * @param taxDetails + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), + savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); + } + } + } + createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, + paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } + } + } + createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, true); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, false); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * savingsProductId + * @param paymentTypeId + * paymentTypeId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param isReversal + * isReversal + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, + final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public Office getOfficeById(final long officeId) { + return this.officeRepository.getReferenceById(officeId); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) + throws DataAccessException { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, + GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, + String currencyCode, GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, + shareAccountId, transactionId, transactionDate, totalAmount); + createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, + transactionId, transactionDate, totalAmount, chargePaymentDTOs); + } + + public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, + AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); + + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Check for charge-specific mappings for all account types (not just income accounts) + // This allows charge-specific GL account mappings for debit accounts as well + if (chargeId != null) { + final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificAccountMapping != null) { + accountMapping = chargeSpecificAccountMapping; + } + } + return accountMapping.getGlAccount(); + } + + private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, + final Long chargeId) { + + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Vishwas TODO: remove this condition as it should always be true + + if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() + || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { + GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); + if (glAccount != null) { + return glAccount; + } + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + + accountMapping = chargeSpecificIncomeAccountMapping; + } + } + + 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; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, + CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + + if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + 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; + } + + private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + accountMapping = chargeSpecificIncomeAccountMapping; + } + return accountMapping.getGlAccount(); + } + + private boolean isOrganizationAccount(final int accountMappingTypeId) { + return FinancialActivity.fromInt(accountMappingTypeId) != null; + } + + public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, + final List clientChargePaymentDTOs) { + /*** + * Map to track each account affected and the net credit to be made for a particular account + ***/ + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { + if (clientChargePaymentDTO.getIncomeAccountId() != null) { + final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); + BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in + // the map + if (creditDetailsMap.containsKey(accountId)) { + final BigDecimal existingAmount = creditDetailsMap.get(accountId); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(accountId, chargeSpecificAmount); + } + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = getGLAccountById(entry.getKey()); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + if (isReversal) { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + return totalCreditedAmount; + } + + public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, + final Boolean isReversal) { + final GLAccount account = financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); + if (isReversal) { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + + private GLAccount getGLAccountById(final Long accountId) { + return this.glAccountRepository.getReferenceById(accountId); + } + + public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { + if (chargeRefundChargeType == null + || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { + String errorValue; + errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); + throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", + "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); + } + Integer incomeAccount; + if (chargeRefundChargeType.equalsIgnoreCase("P")) { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); + } else { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); + + } + return incomeAccount; + } + + public JournalEntry persistJournalEntry(JournalEntry journalEntry) { + boolean isNew = journalEntry.isNew(); + JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); + if (isNew && journalEntry.getLoanTransactionId() != null) { + businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); + } + return savedJournalEntry; + } + + private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { + final Map creditDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + BigDecimal amount = chargePaymentDTO.getAmount(); + + creditDetailsMap.merge(account, amount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + + for (Map.Entry entry : creditDetailsMap.entrySet()) { + GLAccount account = entry.getKey(); + BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + + if (isCredit) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + totalCreditedAmount, totalAmount); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java new file mode 100644 index 000000000..f47c5b129 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.service; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +/** + * A book debit: money leaves a savings or current account by book entry, not as cash. + * + *

+ * Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...). + * Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry -- + * cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no + * report shows. + * + *

+ * It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance + * (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's + * transaction, so a later failure rolls the debit back with everything else. + */ +@Service +@RequiredArgsConstructor +public class AccountBookDebitService { + + private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; + + /** Debits a savings account; returns the m_savings_account_transaction id. */ + public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId) { + final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount); + kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + 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, accountId); + 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); + jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** Debits a current account; returns the m_current_account_transaction id. */ + public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId, final String note) { + final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction " + + "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, " + + "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) " + + "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" }); + ps.setLong(1, accountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setString(5, refNo()); + ps.setDate(6, sqlDate); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setString(8, remarks); + ps.setLong(9, userId); + return ps; + }, kh); + jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */ + private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList( + "SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.", + accountId); + } + final int status = ((Number) rows.get(0).get("status_enum")).intValue(); + if (status != 300) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.active", + kind + " account " + accountId + " is not active.", accountId); + } + final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("account_balance_derived"); + final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("on_hold_funds_derived"); + final BigDecimal available = balance.subtract(onHold); + if (available.compareTo(amount) < 0) { + throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance", + "Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available + + ", Requested: " + amount, + accountId); + } + return balance; + } + + private static String refNo() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 20); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 1ae645c56..172e39de7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction; import org.apache.fineract.portfolio.account.domain.AccountTransferType; import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; @@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer private final CurrentAccountRepository currentAccountRepository; private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final AccountingProcessorHelper accountingProcessorHelper; + private final KycRestrictionGuard kycRestrictionGuard; @Transactional @Override @@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toSavingsAccount.productId(), toSavingsAccount.getId()); + toSavingsAccount.productId(), false, toSavingsAccount.getId()); // skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts // DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number. @@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromSavingsAccount.productId(), fromSavingsAccount.getId()); + fromSavingsAccount.productId(), false, fromSavingsAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer( @@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toCurrentAccount.getProductId(), toCurrentAccount.getId()); + toCurrentAccount.getProductId(), true, toCurrentAccount.getId()); performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromCurrentAccount.getProductId(), fromCurrentAccount.getId()); + fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer( @@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount, final LocalDate txnDate) { + // Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is. + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer currentAccountRepository.save(account); if (account.getProductId() != null && txn.getId() != null) { final String currentTxnId = "CA" + txn.getId(); - accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(), - account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null, - account.getId(), currentTxnId, txnDate, amount, false); + accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), + account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), + account.getId(), currentTxnId, txnDate, amount); } return txn; } @@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. */ private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Deposit amount into a current account and save the transaction. */ private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null); final BigDecimal newBalance = balance.add(amount); @@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn, final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal, - final Long counterpartProductId, final Long counterpartEntityId) { + final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { if (account.getProductId() == null || txn.getId() == null) { return; } @@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final String currentTxnId = "CA" + txn.getId(); if (isWithdrawal) { // DR: current account GL CR: counterpart (savings/current) GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - account.getProductId(), account.getId(), - counterpartProductId, counterpartEntityId, + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, currentTxnId, txnDate, amount); } else { // DR: counterpart (savings/current) GL CR: current account GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - counterpartProductId, counterpartEntityId, - account.getProductId(), account.getId(), + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), currentTxnId, txnDate, amount); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java index 58be11f63..4c3d4bcfa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java @@ -1,140 +1,141 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.account.starter; - -import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.apache.fineract.infrastructure.core.config.FineractProperties; -import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; -import org.apache.fineract.infrastructure.core.service.PaginationHelper; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.security.service.SqlValidator; -import org.apache.fineract.infrastructure.security.utils.ColumnValidator; -import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; -import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; -import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; -import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; -import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; -import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; -import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; -import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; -import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; -import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; -import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; -import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; -import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; -import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; -import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.JdbcTemplate; - -@Configuration -public class AccountConfiguration { - - @Bean - @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) - public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { - return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) - public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, - SqlValidator sqlValidator) { - return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) - public AccountTransfersWritePlatformService accountTransfersWritePlatformService( - AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, - AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, - SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, - LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, - AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, - GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, - FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, - CurrentAccountTransactionRepository currentAccountTransactionRepository, - AccountingProcessorHelper accountingProcessorHelper) { - return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, - accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, - loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, - gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, - currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper); - } - - @Bean - @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) - public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, - DatabaseSpecificSQLGenerator sqlGenerator) { - return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) - public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, - ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) - public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, - DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) - public StandingInstructionWritePlatformService standingInstructionWritePlatformService( - StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, - AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { - return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, - accountTransferDetailRepository, standingInstructionRepository); - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.starter; + +import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; +import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; +import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; +import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; +import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; +import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; +import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; +import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; +import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; +import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; +import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class AccountConfiguration { + + @Bean + @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) + public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { + return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) + public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, + SqlValidator sqlValidator) { + return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) + public AccountTransfersWritePlatformService accountTransfersWritePlatformService( + AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, + AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, + SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, + LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, + AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, + GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, + FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, + CurrentAccountTransactionRepository currentAccountTransactionRepository, + AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) { + return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, + accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, + loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, + gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, + currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard); + } + + @Bean + @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) + public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, + DatabaseSpecificSQLGenerator sqlGenerator) { + return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) + public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, + ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) + public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, + DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) + public StandingInstructionWritePlatformService standingInstructionWritePlatformService( + StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, + AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { + return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, + accountTransferDetailRepository, standingInstructionRepository); + } +} 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 5c128b357..66d0c6950 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 @@ -340,7 +340,7 @@ public class ChequeBookIssuanceChargeService { private Long getCurrentControlGlId(final Long currentAccountId) { final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java index 5fd98fe17..964c29498 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java @@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService { if ("CURRENT".equalsIgnoreCase(accountType)) { return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId); } return null; } 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 e75955127..b8cc344d9 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 @@ -173,9 +173,16 @@ public class ClerkDisbursementRequestApiResource { // 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. + // A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against + // m_current_account -- checking it as a savings id would test some unrelated savings account. if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { - kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), - "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); + final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER + : KycRestrictionGuard.Operation.CREDIT; + if ("CURRENT".equals(entryAccountType)) { + kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation); + } else { + kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation); + } } // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should // not learn the account can't pay out until a manager tries to approve it and the whole @@ -522,6 +529,7 @@ public class ClerkDisbursementRequestApiResource { // collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode). // disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode). final List collectionGlEntries = new ArrayList<>(); + final List collectionCreditGlEntries = new ArrayList<>(); final List disbursementGlEntries = new ArrayList<>(); // Compensation list: each successfully posted Fineract transaction gets an undo lambda. @@ -642,8 +650,9 @@ public class ClerkDisbursementRequestApiResource { // Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description); } else { - // Collection: CR GL is the target for the combined savings debit entry + // Collection: each CR GL takes its own amount in the one combined entry collectionTargetGlId = glId; + collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null)); } } txnRefs.add("GL:" + acNo); @@ -688,28 +697,26 @@ public class ClerkDisbursementRequestApiResource { undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { - try { - txnId = isCurrentAccount - ? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description) - : executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description); - } catch (IdempotentCommandProcessSucceedException idempotent) { - txnId = extractResourceIdFromResponse(idempotent.getResponse()); + // Collection: a direct book debit, exactly as a bank transfer debits. It used to run a + // cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never + // moved) was then hidden by flagging it reversed, and a current account's auto-entry was + // not even hidden, crediting CashVault instead of the target GL. Now nothing is posted + // here; the one combined entry below is the only GL posting. + final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == null) { + throw new RuntimeException("No control GL mapped for account " + acNo + + ". Map it on the account's product Accounting tab."); } - if (txnId != null) { - registerUndo(undoOps, accountId, txnId, isCurrentAccount); - txnRefs.add(txnId); - if (!isCurrentAccount) { - final String fullTxnId = "S" + txnId; - withdrawalSavingsTxnRefs.add(txnId); - withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - // Savings withdrawal auto-creates: DR SavingsControl / CR CashVault. - // Collect the SavingsControl GL (DR side, type_enum=2) so we can - // post a direct DR SavingsControl / CR targetGL combined entry. - final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2); - if (savingsCtrlGl != null) { - collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId)); - } - } + final long debitTxnId = isCurrentAccount + ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); + txnRefs.add(String.valueOf(debitTxnId)); + withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + if (isCurrentAccount) { + undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); + } else { + undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId)); } } } @@ -732,39 +739,22 @@ public class ClerkDisbursementRequestApiResource { } // ── Collection GL entry ─────────────────────────────────────────────── - // Goal: DR SavingsControl(per account) / CR targetGL — one combined entry. - // Fineract withdrawals auto-create: DR SavingsControl / CR CashVault. - // We mark those auto-entries reversed (hidden from GL reports) and post - // the direct combined entry. Fallback: CashVault bridge if GL lookup fails. - if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) { - if (!collectionGlEntries.isEmpty() - && collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) { - markTxnGlEntriesReversed(collectionGlEntries); - final List snapC = new ArrayList<>(collectionGlEntries); - undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC)); - final String collGlTxnId = postCombinedJournalEntry( - collectionGlEntries, - List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)), - officeId, currencyCode, txnDateStr, - buildGlEntryComment("Collection", collectionGlEntries, description)); - // Link each DR entry to its savings account so ACCOUNT NO shows in the GL view. - if (collGlTxnId != null) { - linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); - } - approvedGlTxnId = collGlTxnId; - } else { - final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); - if (cashVaultGlId != null) { - 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); - } + // One combined entry and nothing else: DR each account's own product control GL / CR each + // target GL. The accounts were debited directly above, so there is no auto-posted entry to + // hide and no CashVault leg to bridge. + if (!collectionGlEntries.isEmpty()) { + if (collectionCreditGlEntries.isEmpty()) { + throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + "."); } + final String collGlTxnId = postCombinedJournalEntry( + collectionGlEntries, collectionCreditGlEntries, + officeId, currencyCode, txnDateStr, + buildGlEntryComment("Collection", collectionGlEntries, description)); + // Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view. + if (collGlTxnId != null) { + linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); + } + approvedGlTxnId = collGlTxnId; } } // end else (disbursement/collection mode) @@ -1590,35 +1580,6 @@ public class ClerkDisbursementRequestApiResource { return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue(); } - /** - * Marks all Fineract auto-created journal entries for these transactions as reversed=true. - * Reversed entries are excluded from GL reports — only our direct combined entry is visible. - * Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails. - */ - 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 = ? AND reversed = false", - e.txnId); - } - } - } - - /** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */ - private void unmarkTxnGlEntriesReversed(final List entries) { - for (final GlEntry e : entries) { - if (e.txnId != null) { - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true", - e.txnId); - } - } - } - /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) * row to its savings account so the GL view shows ACCOUNT NO instead of "—". @@ -1735,7 +1696,7 @@ public class ClerkDisbursementRequestApiResource { final List> rows = jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } 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 index ba568c32c..912af3e3e 100644 --- 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 @@ -108,8 +108,20 @@ public class KycRestrictionGuard { checkKycExpiry(savingsAccountId, operation); } + /** + * The same two restrictions for a current account, read off the current account and its own current product + * (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's. + */ + public void checkCurrentAccount(final Long currentAccountId, final Operation operation) { + checkCurrentAccountDormancy(currentAccountId, operation); + enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation); + } + private void checkKycExpiry(final Long savingsAccountId, final Operation operation) { - final Long daysOverdue = daysOverdue(savingsAccountId); + enforceKycStage(daysOverdue(savingsAccountId), operation); + } + + private void enforceKycStage(final Long daysOverdue, final Operation operation) { if (daysOverdue == null) { return; } @@ -158,6 +170,28 @@ public class KycRestrictionGuard { 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); + refuseIfDormant(rows, operation, "savings"); + } + + private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) { + if (currentAccountId == null || operation == Operation.CREDIT) { + return; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT cp.days_to_dormancy, + (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') + - GREATEST(ca.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL), + ca.activatedon_date)) AS days_idle + FROM m_current_account ca + JOIN m_current_product cp ON cp.id = ca.product_id + WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0 + """, currentAccountId); + refuseIfDormant(rows, operation, "current"); + } + + private void refuseIfDormant(final List> rows, final Operation operation, final String accountKind) { if (rows.isEmpty()) { return; } @@ -168,11 +202,30 @@ public class KycRestrictionGuard { } 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(), + throw new PlatformDataIntegrityException("error.msg." + accountKind + ".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)."); } + private Long currentAccountDaysOverdue(final Long currentAccountId) { + if (currentAccountId == 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_current_account ca + JOIN m_client_kyc k ON k.client_id = ca.client_id + WHERE ca.id = ? AND k.expiry_date IS NOT NULL + ORDER BY k.expiry_date DESC + LIMIT 1 + """, currentAccountId); + if (rows.isEmpty()) { + return null; + } + final Number daysOverdue = (Number) rows.get(0).get("days_overdue"); + return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue(); + } + /** * Days past the customer's re-KYC due date, or null where there is nothing overdue to measure. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java new file mode 100644 index 000000000..8e297fafb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java @@ -0,0 +1,850 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.ossreturn.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.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE). + * + *

What these returns are

+ * + * OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and + * loss account on the same footing. Both are quarterly, both are denominated in thousands of + * rupees, and both are filed through RBI's CIMS portal. + * + *

Where every figure comes from

+ * + * Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role + * decide the figure, and the ledger decides its value: + * + *
    + *
  • Balance-sheet roles (OSS 1) take the closing balance as at the quarter end - every + * journal entry on or before that date, on the account's own natural side.
  • + *
  • Earnings roles (OSS 2) take the movement over the quarter, because an income or + * expense line reports what was earned or spent in the period, not a balance carried forward.
  • + *
  • Counts (depositors, borrowers, members) come from the portfolio tables, since a + * headcount has no ledger balance to read.
  • + *
+ * + * The bank's own identity - name, RBI bank code, regional office, signatories - is read from + * {@code m_institution_profile}, the single place the institution's particulars are maintained. + * Nothing about the bank is written into this file. + * + *

Blank is not zero

+ * + * A role with no accounts pinned to it reports {@code null} and says so in + * {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the + * bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and + * the two must not be filed as though they were the same statement. + */ +@Path("/v1/ossreturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class OssReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN"; + + /** Returns are denominated in thousands of rupees; the ledger is in rupees. */ + private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000); + + /** + * acc_gl_account.classification_enum. Only the debit-natured classifications are named, since + * they are what decides which way round a balance is read; everything else is credit-natured. + */ + private static final int ASSET = 1; + private static final int INCOME = 4; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + /** + * Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever + * income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong. + */ + private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__"; + + /** m_savings_account.status_enum for an active account. */ + private static final int SAVINGS_ACTIVE = 300; + + /** m_loan.loan_status_id for an active loan. */ + private static final int LOAN_ACTIVE = 300; + + /** + * m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head + * covers both fixed and recurring deposits, so the two are counted together there. + */ + private static final int SAVINGS_DEPOSIT = 100; + private static final List TERM_DEPOSIT_TYPES = List.of(200, 300); + + /** How RBI prints dates on the General Information sheet. */ + private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + // ─────────────────────────── OSS 1 — RAL ─────────────────────────── + + /** + * OSS 1 - Return on Assets and Liabilities, return code R070. + * + * @param quarterEndDateParam + * any date in the quarter being reported; the quarter end is derived from it, so a + * caller never has to know which day a quarter ends on. Defaults to the current + * business date. + */ + @GET + @Path("oss1") + public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter, + officeId); + response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note", + "Balance-sheet figures are the closing position as at " + quarter.end + + "; every journal entry on or before that date is included.")); + + // Liabilities and assets, in the order the RBI workbook prints them. Each line names the + // role whose mapped accounts produce it; a line with no role of its own is a subtotal and + // is summed from the lines it covers, never mapped separately. + final List> liabilities = new ArrayList<>(); + final RoleFigures figures = new RoleFigures(quarter, officeId); + + liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures)); + liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures)); + liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures)); + + // The year's own result, which the ledger has not appropriated to reserves yet. RBI prints + // it as its own head for exactly that reason: between appropriations the balance sheet is + // short by what the bank has earned or lost so far, and without this line the return would + // not balance even though the books do. Derived from the income and expense accounts rather + // than mapped, since it is not a balance any account carries. + final BigDecimal currentYearResult = currentYearResult(quarter, officeId); + liabilities.add(derivedLine( + currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year" + : "3 iv. Operating Deficit in Current year", + CURRENT_YEAR_RESULT, currentYearResult)); + + liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT)); + liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures)); + liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures)); + liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null))); + liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures)); + liabilities.add( + countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES))); + liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures)); + liabilities.add(countLine("No of Savings Depositors", + countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT)))); + liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures)); + liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures)); + liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures)); + // The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total + // names the head and leaves them out rather than counting the same money twice. + liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS", + "OSS1_OTHER_LIABILITIES")); + + final List> assets = new ArrayList<>(); + assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures)); + assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures)); + assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures)); + assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures)); + assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end))); + assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures)); + assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures)); + assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS", + "OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS")); + + response.put("liabilities", liabilities); + response.put("assets", assets); + + // Liabilities must equal assets. Reported rather than asserted: an imbalance is a real + // condition of the books that the filer has to see, not something to hide behind an error. + final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities"); + final BigDecimal totalAssets = amountOf(assets, "12. Total Assets"); + response.put("totalLiabilities", totalLiabilities); + response.put("totalAssets", totalAssets); + response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0); + response.put("imbalance", totalAssets.subtract(totalLiabilities)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── OSS 2 — RoE ─────────────────────────── + + /** + * OSS 2 - Return on Earnings, return code R071. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the + * period matters here in a way it does not on OSS 1: the same account read as a balance would + * carry forward every earlier quarter's income as well. + */ + @GET + @Path("oss2") + public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId); + response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note", + "Earnings are the movement over " + quarter.start + " to " + quarter.end + + ", not a closing balance: an income or expense line reports what was earned or spent " + + "in the quarter.")); + + final RoleFigures figures = new RoleFigures(quarter, officeId); + + final List> earnings = new ArrayList<>(); + earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES", + figures)); + earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING", + figures)); + earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures)); + earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits", + "OSS2_INTEREST_ON_BANK_BALANCES", figures)); + earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES")); + earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE", + figures)); + earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures)); + earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures)); + + final List> expenses = new ArrayList<>(); + expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures)); + expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures)); + expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures)); + expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures)); + expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures)); + expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures)); + expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures)); + + response.put("earnings", earnings); + response.put("expenses", expenses); + + // Item 1 is the sum of items 2 to 5, so total income counts the four components and the + // standalone heads - never item 1 as well, which would count that interest twice. + final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES", + "OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME"); + final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS", + "OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE", + "OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES"); + response.put("totalIncome", totalIncome); + response.put("totalExpenditure", totalExpenditure); + response.put("operatingProfit", totalIncome.subtract(totalExpenditure)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── shared response scaffolding ─────────────────────────── + + /** + * The General Information block every OSS return carries, built from the institution's own + * particulars so OSS 1 and OSS 2 can never state a different bank from one another. + */ + private Map baseResponse(final String returnCode, final String returnName, final Quarter quarter, + final Long officeId) { + final Map response = new LinkedHashMap<>(); + response.put("returnCode", returnCode); + response.put("returnName", returnName); + response.put("reportingFrequency", "Quarterly"); + + final Map profile = institutionProfile(); + final List> generalInfo = new ArrayList<>(); + generalInfo.add(info("Return Name", returnName)); + generalInfo.add(info("Return Code", returnCode)); + generalInfo.add(info("Bank Name", text(profile.get("bank_name")))); + generalInfo.add(info("Bank Code", text(profile.get("bank_code")))); + generalInfo.add(info("RO Name", text(profile.get("city")))); + generalInfo.add(info("Institution Type", text(profile.get("bank_type")))); + generalInfo.add(info("Reporting Frequency", "Quarterly")); + generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE))); + generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE))); + response.put("generalInfo", generalInfo); + + response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate", + quarter.end.toString())); + response.put("officeId", officeId); + response.put("officeName", officeName(officeId)); + response.put("signatories", signatories(profile, quarter)); + return response; + } + + /** + * The institution's particulars. Absent columns are tolerated rather than fatal: the return + * names what is missing on the General Information sheet, which is more useful than refusing to + * compute figures that are themselves correct. + */ + private Map institutionProfile() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT bank_name, bank_code, bank_type, city, state, + authorized_person_name, authorized_person_designation, authorized_person_email, + contact_phone + FROM m_institution_profile WHERE id = 1 + """); + return rows.isEmpty() ? Map.of() : rows.get(0); + } + + /** + * The signatory block, taken from the institution's authorised person. Dated to the quarter + * end rather than to today, so a return reprinted later still reads as filed for its own + * period. + */ + private List> signatories(final Map profile, final Quarter quarter) { + final Map signatory = new LinkedHashMap<>(); + signatory.put("heading", "Authorised Reporting Official"); + signatory.put("name", text(profile.get("authorized_person_name"))); + signatory.put("designation", text(profile.get("authorized_person_designation"))); + signatory.put("email", text(profile.get("authorized_person_email"))); + signatory.put("telephoneOffice", text(profile.get("contact_phone"))); + signatory.put("place", text(profile.get("city"))); + signatory.put("date", quarter.end.format(RBI_DATE)); + return List.of(signatory); + } + + private String officeName(final Long officeId) { + if (officeId == null) { + return "All offices (consolidated)"; + } + final List names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, + officeId); + return names.isEmpty() ? "Office " + officeId : names.get(0); + } + + private static Map info(final String label, final String value) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + // An unset particular is reported as such rather than as an empty cell, so a reviewer can + // see that it needs maintaining in the institution profile. + out.put("value", value.isBlank() ? "Not set in Institution Profile" : value); + out.put("configured", !value.isBlank()); + return out; + } + + private static String text(final Object value) { + return value == null ? "" : value.toString().trim(); + } + + // ─────────────────────────── the quarter ─────────────────────────── + + /** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */ + private static final class Quarter { + + final LocalDate start; + final LocalDate end; + final String label; + + private Quarter(final LocalDate start, final LocalDate end, final String label) { + this.start = start; + this.end = end; + this.label = label; + } + + static Quarter containing(final LocalDate date) { + final int month = date.getMonthValue(); + final int startMonth = ((month - 1) / 3) * 3 + 1; + final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1); + final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear()); + } + } + + // ─────────────────────────── figures from the ledger ─────────────────────────── + + /** + * Resolves return lines to ledger figures, remembering which roles were unmapped or partly + * unusable so the return can report them once at the end. + */ + private final class RoleFigures { + + private final Quarter quarter; + private final Long officeId; + private final List reasons = new ArrayList<>(); + private boolean anyConfigured; + + RoleFigures(final Quarter quarter, final Long officeId) { + this.quarter = quarter; + this.officeId = officeId; + } + + /** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */ + RoleFigure closing(final String roleKey) { + return figureFor(roleKey, null, quarter.end, officeId, this); + } + + /** Movement over the quarter - for the earnings lines of OSS 2. */ + RoleFigure movement(final String roleKey) { + return figureFor(roleKey, quarter.start, quarter.end, officeId, this); + } + + /** + * Named by the line as the return prints it rather than by the role key: the reader is + * looking at a return, and "Investments" is what they will look for on the configuration + * screen, not OSS1_INVESTMENTS. + */ + void noteUnmapped(final String roleKey) { + reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing."); + } + + void noteExcluded(final String roleKey, final List excluded) { + reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded) + + ", mapped but no longer postable. The figure shown is short by them."); + } + + void noteConfigured() { + anyConfigured = true; + } + + boolean anyConfigured() { + return anyConfigured; + } + + List unmet() { + return reasons; + } + } + + /** A role's figure, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String roleKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + final List excluded = new ArrayList<>(); + + RoleFigure(final String roleKey) { + this.roleKey = roleKey; + } + } + + /** + * Sums the GL accounts pinned to a role. + * + * With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a + * from-date it is the movement between the two, which is what an earnings line reports. + * + * Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL + * would shrink the figure with nothing on the return to say an account had dropped out, so + * they are carried through and named instead. + */ + private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate, + final Long officeId, final RoleFigures owner) { + final RoleFigure figure = new RoleFigure(roleKey); + + final List> accounts = jdbcTemplate.queryForList(""" + SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code + """, roleKey); + if (accounts.isEmpty()) { + owner.noteUnmapped(roleKey); + return figure; + } + figure.configured = true; + owner.noteConfigured(); + + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = text(account.get("gl_code")); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into + // a header whose balance is the sum of its children and would double-count them. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded + .add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", text(account.get("name"))); + skipped.put("balance", null); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId); + figure.amount = figure.amount.add(balance); + + final Map detail = new LinkedHashMap<>(); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", text(account.get("name"))); + detail.put("balance", scaled(balance)); + figure.accounts.add(detail); + } + + if (!figure.excluded.isEmpty()) { + owner.noteExcluded(roleKey, figure.excluded); + } + return figure; + } + + /** + * One account's ledger figure, on its own natural side so a liability reports positive rather + * than as a negative asset. + * + * Reversed entries and the reversal entries that cancel them are both excluded, so a reversal + * nets to nothing rather than to twice the original. + */ + private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate, + final LocalDate toDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT 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 account_id = ? AND 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) + """); + final List args = new ArrayList<>(); + args.add(accountId); + args.add(java.sql.Date.valueOf(toDate)); + if (fromDate != null) { + sql.append(" AND entry_date >= ?"); + args.add(java.sql.Date.valueOf(fromDate)); + } + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = toDecimal(row.get("total_debit")); + final BigDecimal credit = toDecimal(row.get("total_credit")); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + return debitNatured ? debit.subtract(credit) : credit.subtract(debit); + } + + /** + * A role's name as the return prints it, for the caveats. Falls back to the key so a role + * added without a name here degrades to something readable rather than to nothing. + */ + private static String lineName(final String roleKey) { + return LINE_NAMES.getOrDefault(roleKey, roleKey); + } + + private static final Map LINE_NAMES = new LinkedHashMap<>(); + static { + LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital"); + LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves"); + LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss"); + LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)"); + LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts"); + LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits"); + LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings"); + LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions"); + LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand"); + LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks"); + LINE_NAMES.put("OSS1_INVESTMENTS", "Investments"); + LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances"); + LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets"); + LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets"); + + LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances"); + LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending"); + LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments"); + LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances"); + LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage"); + LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments"); + LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings"); + LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund"); + LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting"); + LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation"); + LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies"); + LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses"); + } + + /** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */ + private static BigDecimal toDecimal(final Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + } + + /** Rupees to the thousands the return is denominated in. */ + private static BigDecimal scaled(final BigDecimal rupees) { + return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP); + } + + // ─────────────────────────── headcounts ─────────────────────────── + + /** + * Depositors as at a date - distinct clients holding a deposit account open on that date. + * Counted per client rather than per account, because a customer with three accounts is one + * depositor on the return. + * + * An account is open on the date if it was active by then and had not yet closed. Reading it + * this way, rather than by today's status, is what lets an earlier quarter be reprinted and + * still report the headcount that was true then. + */ + private Long countDepositors(final Long officeId, final LocalDate asOn, final List depositTypes) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT sa.client_id) + FROM m_savings_account sa + JOIN m_client c ON c.id = sa.client_id + WHERE sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + AND sa.activatedon_date <= ? + AND (sa.closedon_date IS NULL OR sa.closedon_date > ?) + """); + final List args = new ArrayList<>(); + args.add(SAVINGS_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + args.add(java.sql.Date.valueOf(asOn)); + if (depositTypes != null && !depositTypes.isEmpty()) { + sql.append(" AND sa.deposit_type_enum IN (") + .append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")"); + args.addAll(depositTypes); + } + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + /** + * The financial year's result up to the quarter end: income earned less expenses incurred. + * + * Read across every income and expense account rather than the mapped ones, because this line + * exists to close the gap between the balance sheet and the books. A figure limited to mapped + * accounts would leave the return short by whatever was not mapped, which is the very problem + * the line is here to solve. + * + * The year starts on 1 April, as the Indian financial year does. + */ + private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) { + final LocalDate yearStart = financialYearStart(quarter.end); + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ? + THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END + ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END + END), 0) AS result + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.classification_enum IN (?, ?) + AND je.entry_date >= ? AND je.entry_date <= ? + AND (je.reversed IS NULL OR je.reversed = false) + AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(); + args.add(INCOME); + args.add(INCOME); + args.add(EXPENSE); + args.add(java.sql.Date.valueOf(yearStart)); + args.add(java.sql.Date.valueOf(quarter.end)); + if (officeId != null) { + sql.append(" AND je.office_id = ?"); + args.add(officeId); + } + + final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray()); + return result == null ? BigDecimal.ZERO : result; + } + + /** 1 April of the financial year a date falls in. */ + private static LocalDate financialYearStart(final LocalDate date) { + final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return LocalDate.of(year, 4, 1); + } + + /** Borrowers as at a date - distinct clients with a loan outstanding on that date. */ + private Long borrowerCount(final Long officeId, final LocalDate asOn) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT l.client_id) + FROM m_loan l + JOIN m_client c ON c.id = l.client_id + WHERE l.loan_status_id = ? + AND l.disbursedon_date IS NOT NULL + AND l.disbursedon_date <= ? + """); + final List args = new ArrayList<>(); + args.add(LOAN_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + // ─────────────────────────── line builders ─────────────────────────── + + private Map balanceLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.closing(roleKey)); + } + + private Map movementLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.movement(roleKey)); + } + + private static Map line(final String label, final RoleFigure figure) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", figure.roleKey); + out.put("configured", figure.configured); + // Null rather than zero when unmapped: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? scaled(figure.amount) : null); + out.put("accounts", figure.accounts); + out.put("subtotal", false); + return out; + } + + /** + * A figure the return computes for itself rather than reading from mapped accounts. + * + * It carries a role key so the subtotals can name it like any other component, but that key is + * not a configurable role: nothing can leave it unmapped, and nothing can map it wrong. + */ + private static Map derivedLine(final String label, final String roleKey, + final BigDecimal rupees) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", roleKey); + out.put("configured", true); + out.put("amount", scaled(rupees)); + out.put("accounts", List.of()); + out.put("derived", true); + out.put("subtotal", false); + return out; + } + + private static Map countLine(final String label, final Long count) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", count); + out.put("accounts", List.of()); + out.put("count", true); + out.put("subtotal", false); + return out; + } + + /** + * A derived row, summed from the roles it names. Subtotals are never mapped to accounts of + * their own - a total and its components mapped separately would disagree the moment one side + * changed - and they name their components explicitly rather than summing whatever happens to + * sit above them, so inserting a line cannot silently change a total. + */ + private static Map subtotal(final String label, final List> section, + final String... ofRoles) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", sumOfRoles(section, ofRoles)); + out.put("accounts", List.of()); + out.put("components", List.of(ofRoles)); + out.put("subtotal", true); + return out; + } + + /** The amount already computed for a labelled row, so a total is never derived twice. */ + private static BigDecimal amountOf(final List> section, final String label) { + for (final Map line : section) { + if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) { + return decimal; + } + } + return BigDecimal.ZERO; + } + + /** + * Sums the named roles out of a section. + * + * A component that is unmapped contributes nothing, which is why the return still reports + * {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by + * whatever is not. + */ + private static BigDecimal sumOfRoles(final List> section, final String... roles) { + final Set wanted = new LinkedHashSet<>(Arrays.asList(roles)); + BigDecimal total = BigDecimal.ZERO; + for (final Map line : section) { + // Subtotals and headcounts carry no role of their own, so their key is null. Matched by + // the role rather than by a null-tolerant lookup, so a derived row can never be summed + // into the total that derives it. + final Object roleKey = line.get("roleKey"); + if (roleKey == null || !wanted.contains(roleKey.toString())) { + continue; + } + if (line.get("amount") instanceof BigDecimal decimal) { + total = total.add(decimal); + } + } + return total; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java new file mode 100644 index 000000000..36b1a0d19 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -0,0 +1,687 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.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 RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR + * percentages and the GL accounts the returns read their figures from. + * + * Both halves live on one screen because they are one decision: a percentage is meaningless without + * knowing which accounts it applies to, and an administrator who sets one and forgets the other + * produces a return that looks complete and is wrong. + * + * Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike + * m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other + * banks and each counts toward the balances it maintains. + */ +@Path("/v1/rbireserveconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** + * The roles a return can read, in the order the screen presents them. Held here rather than + * seeded as rows because they are structural - a role only exists if code computes something + * from it, so adding one is a code change either way. + */ + private static final Map ROLE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + ROLE_DESCRIPTIONS.put("CASH_IN_HAND", + "Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained."); + ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS", + "Current account balances the bank holds with other banks. Counts toward CRR maintained. " + + "Pin every such account - a bank typically banks with several."); + ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES", + "Deposit liabilities forming the DTL base that CRR and SLR are charged on. " + + "Pin the deposit accounts and any interest accrued on them."); + ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS", + "SLR-eligible investments - central and state government securities. " + + "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR."); + + // OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its + // closing balance at the quarter end. + ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL", + "Share capital paid up by members. OSS 1 liability head 1."); + ROLE_DESCRIPTIONS.put("OSS1_RESERVES", + "Reserve fund and other reserves - statutory, building, dividend equalisation, " + + "provisions for standard assets. OSS 1 liability head 2."); + ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS", + "Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3."); + ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT", + "Subordinated debt counted as Tier II capital. OSS 1 liability head 5."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS", + "All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; " + + "the term/savings/current heads below break this same total down and are not added to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM", + "Term deposits, including recurring deposits and matured term deposits. " + + "A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS", + "Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT", + "Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS", + "Borrowings from banks and other institutions. OSS 1 liability head 7."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES", + "Other liabilities and provisions - bills payable, interest accrued, sundry creditors."); + ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND", + "Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND."); + ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS", + "Balances with banks - current, savings and fixed. " + + "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements."); + ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS", + "All investments, SLR and non-SLR together. OSS 1 asset head 3."); + ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES", + "Loans and advances to members and others. OSS 1 asset head 4."); + ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS", + "Premises, furniture and other fixed assets, net of depreciation."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery."); + + // OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an + // earnings line reports what was earned or spent in the period. + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES", + "Interest and discount earned on loans and advances. OSS 2 item 2."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING", + "Interest on lending to banks - call and short notice money. OSS 2 item 3."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES", + "Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5."); + ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", + "Commission, exchange and brokerage earned. OSS 2 item 6."); + ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS", + "Profit on sale of investments. Pin the income account only; losses belong under provisions."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11."); + ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST", + "Salaries, allowances, provident fund and other staff cost. OSS 2 item 12."); + ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13."); + ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14."); + ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS", + "Provisions and contingencies, including bad debt provision and loss on investments."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16."); + } + + /** + * A readable name per role. Held next to the descriptions rather than in the screen, so a role + * added here cannot show up in the UI as a raw key nobody wrote a label for. + */ + private static final Map ROLE_LABELS = new LinkedHashMap<>(); + static { + ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand"); + ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks"); + ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)"); + ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments"); + + ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital"); + ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves"); + ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss"); + ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)"); + ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts"); + ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits"); + ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings"); + ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions"); + ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand"); + ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks"); + ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments"); + ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances"); + ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets"); + ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets"); + + ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances"); + ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending"); + ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments"); + ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances"); + ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage"); + ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments"); + ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings"); + ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF"); + ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting"); + ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation"); + ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies"); + ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses"); + } + + /** + * Which return each role belongs to, and how it is used there, so the screen can separate a + * long list into the forms it affects and state the consequence of leaving one unmapped. + */ + private static final Map ROLE_GROUPS = new LinkedHashMap<>(); + private static final Map ROLE_GROUP_LABELS = new LinkedHashMap<>(); + private static final Map ROLE_USAGE = new LinkedHashMap<>(); + static { + ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)"); + ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities"); + ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings"); + + for (final String role : ROLE_DESCRIPTIONS.keySet()) { + if (role.startsWith("OSS1_")) { + ROLE_GROUPS.put(role, "OSS1"); + ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end"); + } else if (role.startsWith("OSS2_")) { + ROLE_GROUPS.put(role, "OSS2"); + ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter"); + } else { + ROLE_GROUPS.put(role, "RESERVE"); + if ("SLR_INVESTMENTS".equals(role)) { + ROLE_USAGE.put(role, "Form 9 (SLR) — maintained"); + } else if ("DEMAND_TIME_LIABILITIES".equals(role)) { + ROLE_USAGE.put(role, "Form 1 & Form 9 — base"); + } else { + ROLE_USAGE.put(role, "Form 1 (CRR) — maintained"); + } + } + } + } + + /** + * SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits + * beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and + * effective-dated so a refiled return reproduces the split that was in force. + */ + private static final List RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); + + /** The four treatments a head can be given, each naming a branch the returns already have. */ + private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "MEMO"); + + private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head"); + USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL"); + USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward what is maintained against the requirement"); + USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total"); + } + + /** How each built-in role is treated, so the screen can describe built-ins and additions alike. */ + private static final Map BUILT_IN_USAGE = new LinkedHashMap<>(); + static { + BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY"); + BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED"); + BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED"); + BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED"); + } + + /** Roles whose accounts carry a Demand/Time class. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final Map out = new LinkedHashMap<>(); + out.put("rates", rates()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + // Header accounts are excluded deliberately. A header carries no journal entry of its own - + // its balance is the sum of its children - so pinning both a header and any child would + // double-count that child. Only postable detail accounts are offered. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code, classification_enum + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 + ORDER BY gl_code + """); + 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")); + o.put("classification", a.get("classification_enum")); + options.add(o); + } + return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES)); + } + + private List> rates() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, rate_type, percentage, effective_from, is_seed_default + FROM m_rbi_reserve_rate + ORDER BY rate_type, effective_from DESC + """); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", row.get("id")); + o.put("rateType", row.get("rate_type")); + o.put("percentage", row.get("percentage")); + o.put("effectiveFrom", String.valueOf(row.get("effective_from"))); + // A placeholder shipped with the migration, not a figure this bank confirmed. + o.put("seedDefault", row.get("is_seed_default")); + out.add(o); + } + return out; + } + + /** Every role, including those with nothing pinned - the screen must show the gaps. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class, + a.name AS gl_account_name, a.gl_code + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + ORDER BY c.config_key, a.gl_code, c.liability_class + """); + + // Seeded with every key the configuration knows, built in or added, so a mapping against a + // head an administrator created lands in a bucket rather than being dropped as unknown. + final Map>> byKey = new LinkedHashMap<>(); + for (final String key : knownRoleKeys()) { + byKey.put(key, new ArrayList<>()); + } + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + // A row whose key is no longer a known role is ignored rather than surfaced - it cannot + // affect any figure, because nothing reads that key. + final List> bucket = byKey.get(key); + if (bucket == null) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("id", row.get("id")); + account.put("glAccountId", row.get("gl_account_id")); + account.put("glAccountName", row.get("gl_account_name")); + account.put("glCode", row.get("gl_code")); + // The portion of the balance this mapping takes, and which head it falls under. Sent + // back so the screen edits what is stored rather than re-deriving a default that would + // overwrite a deliberate split on the next save. + account.put("weightPercentage", row.get("weight_percentage")); + account.put("liabilityClass", row.get("liability_class")); + bucket.add(account); + } + + final List> out = new ArrayList<>(); + for (final Map.Entry role : ROLE_DESCRIPTIONS.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("description", role.getValue()); + o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey())); + // Which return reads this role, so the screen can group a long list by the form it + // affects rather than presenting every role as equally relevant to every return. + final String group = ROLE_GROUPS.get(role.getKey()); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.get(group)); + o.put("usedBy", ROLE_USAGE.get(role.getKey())); + // Built-in roles are wired into the arithmetic by name, so they cannot be renamed or + // removed - the screen greys those controls rather than offering an action that would + // have to fail. + o.put("builtIn", true); + o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO")); + o.put("classified", LIABILITY_ROLES.contains(role.getKey())); + o.put("accounts", byKey.get(role.getKey())); + out.add(o); + } + // Heads an administrator added. Appended after the built-ins so the structural roles keep + // their fixed order at the top of each group regardless of what was added later. + for (final Map row : customRoleRows()) { + final String key = (String) row.get("config_key"); + final String group = (String) row.get("role_group"); + final String usage = (String) row.get("role_usage"); + final Map o = new LinkedHashMap<>(); + o.put("configKey", key); + o.put("description", row.get("description")); + o.put("label", row.get("label")); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group)); + o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage)); + o.put("builtIn", false); + o.put("usage", usage); + // A liability head carries the demand/time split; nothing else does. + o.put("classified", "LIABILITY".equals(usage)); + o.put("roleId", row.get("id")); + o.put("accounts", byKey.getOrDefault(key, new ArrayList<>())); + out.add(o); + } + return out; + } + + /** Administrator-defined heads, in the order the screen presents them. */ + private List> customRoleRows() { + return jdbcTemplate.queryForList(""" + SELECT id, config_key, label, description, role_group, role_usage, position + FROM m_rbi_reserve_role + ORDER BY role_group, position, label + """); + } + + /** Every role key the configuration currently knows - built in or added. */ + private java.util.Set knownRoleKeys() { + final java.util.Set keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet()); + for (final Map row : customRoleRows()) { + keys.add((String) row.get("config_key")); + } + return keys; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Replaces the whole set of accounts pinned to one role. Sending the full set rather than + * add/remove deltas keeps the screen and the stored configuration in step even if two + * administrators have the page open at once - the last save wins wholesale, instead of the two + * interleaving into a set neither of them chose. + */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + if (!knownRoleKeys().contains(configKey)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role", + "'" + configKey + "' is not a known RBI reserve configuration role."); + } + final boolean classified = isClassifiedRole(configKey); + + final JsonElement root = this.fromJsonHelper.parse(body); + + // Two shapes are accepted. "mappings" carries a weight and a class per account and is what + // the screen sends; "glAccountIds" is the flat list the first version of this screen sent, + // still honoured so an older client keeps working - its accounts are taken whole, which is + // exactly what they meant before weights existed. + final List mappings = new ArrayList<>(); + final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings") + ? root.getAsJsonObject().getAsJsonArray("mappings") + : null; + if (rows != null) { + for (final JsonElement element : rows) { + final com.google.gson.JsonObject row = element.getAsJsonObject(); + final Long accountId = row.get("glAccountId").getAsLong(); + final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull() + ? row.get("weightPercentage").getAsBigDecimal() + : BigDecimal.valueOf(100); + final String liabilityClass = classified && row.has("liabilityClass") + && !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null; + mappings.add(new Mapping(accountId, weight, liabilityClass)); + } + } else { + final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root); + if (ids != null) { + for (final String id : ids) { + mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null)); + } + } + } + + final java.util.Set seen = new java.util.LinkedHashSet<>(); + for (final Mapping mapping : mappings) { + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1", + Integer.class, mapping.accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found", + "GL account " + mapping.accountId + " was not found, is disabled, or is a header account."); + } + // A weight outside 0-100 is not a split of a balance. Above 100 it would report more + // than the account holds; below zero it would subtract a liability from the base. + if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0 + || mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range", + "The portion taken from an account must be between 0 and 100 percent."); + } + if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass) + && !TIME.equals(mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class", + "A liability must be classified as " + DEMAND + " or " + TIME + "."); + } + // The same account may appear twice under one role only if the two rows are different + // heads - a demand slice and a time slice. Twice under the same head is a double count. + if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping", + "GL account " + mapping.accountId + + " is mapped to this role twice under the same head; it would be counted twice."); + } + } + + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey); + for (final Mapping mapping : mappings) { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_gl_config + (config_key, gl_account_id, weight_percentage, liability_class, created_on) + VALUES (?, ?, ?, ?, now()) + """, configKey, mapping.accountId, mapping.weight, mapping.liabilityClass); + } + return retrieveAll(); + } + + /** One account pinned to a role: which account, how much of it, and under which head. */ + private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {} + + /** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */ + private boolean isClassifiedRole(final String configKey) { + if (LIABILITY_ROLES.contains(configKey)) { + return true; + } + final List> found = jdbcTemplate + .queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey); + return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage")); + } + + /** + * Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the + * bank carries that the built-in roles do not name. + * + * The head needs no new code because it declares how it is consumed: a LIABILITY head enters the + * base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head + * counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a + * branch the returns already have. + */ + @POST + @Path("role") + @Consumes({ MediaType.APPLICATION_JSON }) + public String createRole(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String label = this.fromJsonHelper.extractStringNamed("label", root); + final String description = this.fromJsonHelper.extractStringNamed("description", root); + final String group = this.fromJsonHelper.extractStringNamed("group", root); + final String usage = this.fromJsonHelper.extractStringNamed("usage", root); + + if (label == null || label.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label", + "A name is required for the head."); + } + if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group", + "The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + "."); + } + if (usage == null || !ROLE_USAGES.contains(usage)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage", + "How the head is used must be one of " + ROLE_USAGES + "."); + } + + // The key is derived from the name rather than typed, so an administrator naming a head does + // not also have to invent a stable identifier - and cannot collide with a built-in role by + // choosing one of its names. + final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_|_$", ""); + String key = base.length() > 50 ? base.substring(0, 50) : base; + int suffix = 2; + while (knownRoleKeys().contains(key)) { + final String candidate = base + "_" + suffix++; + key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate; + } + + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_role + (config_key, label, description, role_group, role_usage, position, created_on, created_by) + VALUES (?, ?, ?, ?, ?, ?, now(), ?) + """, key, label.trim(), description, group, usage, nextPosition(group), + this.context.authenticatedUser().getId()); + return retrieveAll(); + } + + /** Where a new head sits in its group: after the ones already there. */ + private int nextPosition(final String group) { + final Integer max = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class, + group); + return (max == null ? 100 : max) + 1; + } + + /** + * Removes an administrator-defined head, together with the accounts pinned to it. + * + * Built-in roles are refused: the returns reference them by name, so a deleted one would not + * leave a smaller return but a broken computation. + */ + @DELETE + @Path("role/{roleId}") + public String deleteRole(@PathParam("roleId") final Long roleId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found", + "Head " + roleId + " was not found, or is a built-in role that cannot be removed."); + } + final String key = (String) found.get(0).get("config_key"); + // The mappings go with it. Left behind they would reference a head nothing reads, and would + // silently reappear under a later head that happened to derive the same key. + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key); + jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId); + return retrieveAll(); + } + + /** + * Sets a rate with effect from a date. Restating a rate for a date it already carries edits that + * row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past + * fortnight keeps reporting the rate that was actually in force then. + */ + @POST + @Path("rate") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertRate(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root); + final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root); + final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root); + + if (rateType == null || !RATE_TYPES.contains(rateType)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type", + "Rate type must be one of " + RATE_TYPES + "."); + } + if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0 + || percentage.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range", + "Percentage must be between 0 and 100."); + } + if (effectiveFrom == null) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date", + "An effective-from date is required."); + } + + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class, + rateType, java.sql.Date.valueOf(effectiveFrom)); + if (exists != null && exists > 0) { + // Saving over a seeded placeholder makes it this bank's own figure, so the flag clears + // and the returns stop captioning it as a default. + jdbcTemplate.update(""" + UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false + WHERE rate_type = ? AND effective_from = ? + """, percentage, rateType, java.sql.Date.valueOf(effectiveFrom)); + } else { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by, + is_seed_default) + VALUES (?, ?, ?, now(), ?, false) + """, rateType, percentage, java.sql.Date.valueOf(effectiveFrom), + this.context.authenticatedUser().getId()); + } + return retrieveAll(); + } + + /** + * Removes one effective-dated rate row. The last remaining row for a type is kept: with none at + * all, every return of that type would stop computing, which is a harsher outcome than an + * administrator deleting the wrong row expects. + */ + @DELETE + @Path("rate/{rateId}") + public String deleteRate(@PathParam("rateId") final Long rateId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found", + "Rate " + rateId + " was not found."); + } + final String rateType = (String) found.get(0).get("rate_type"); + final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?", + Integer.class, rateType); + if (remaining != null && remaining <= 1) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row", + "The only " + rateType + " rate on record cannot be removed - edit it instead."); + } + jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId); + return retrieveAll(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java new file mode 100644 index 000000000..2f641343b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -0,0 +1,892 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.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.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +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; + +/** + * Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly). + * + *

What the returns say

+ * + * Both returns answer the same shape of question: against a liability base (Demand and Time + * Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1 + * maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities. + * + *

The lag basis, which is the part worth reading twice

+ * + * The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the + * DTL of an earlier reporting Friday and compares it against what is actually held now - that lag + * is what gives a bank time to arrange its position. So: + * + *
+ *   DTL       as at the reporting Friday on or before (reporting date - 45 days)
+ *   Required  = DTL x rate in force
+ *   Held      as at the reporting date itself
+ *   Surplus   = Held - Required
+ * 
+ * + * The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a + * reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to + * reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a + * reviewer can see the raw 45-day date and the Friday actually used. + * + *

Reading balances

+ * + * Balances are read the way every other report in this codebase reads them (see + * BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND + * the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the + * original leg of that second kind would leave its counter entry standing alone and swing the + * balance by the full amount in the wrong direction. + * + *

When something is not configured

+ * + * A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a + * total that silently treats it as zero. A CRR return that reports zero holdings because nobody + * mapped the cash account is worse than one that says it cannot compute. + */ +@Path("/v1/rbireservereturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */ + private static final int DTL_LAG_DAYS = 45; + + /** + * The share of the requirement that must be held on EVERY day of the fortnight, not merely on + * average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight + * near-empty and make the average up on the closing day. + */ + private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90); + + private static final int ASSET = 1; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + /** The two heads liabilities are reported under. */ + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + /** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + /** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */ + private static final String USAGE_LIABILITY = "LIABILITY"; + private static final String USAGE_ASSET_NET = "ASSET_NET"; + private static final String USAGE_MAINTAINED = "MAINTAINED"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + /** + * Form 1 - CRR, filed twice a month. + * + * @param reportingDateParam + * defaults to the current business date. + */ + @GET + @Path("form1") + public String form1(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate reportingDate = parseOrBusinessDate(reportingDateParam); + final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS); + // A reporting Friday that falls on a holiday has no close of business to strike a balance + // at, so the position is taken at the preceding working day instead. + final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate); + final LocalDate dtlDate = precedingWorkingDay(nominalDtlDate); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_1"); + response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)"); + response.put("frequency", "Fortnightly"); + response.put("reportingDate", reportingDate.toString()); + response.putAll(fortnightOf(reportingDate)); + + // The lag basis, spelled out so a reviewer can check it rather than trust it. + final boolean shifted = !dtlDate.equals(nominalDtlDate); + final Map basis = new LinkedHashMap<>(); + basis.put("lagDays", DTL_LAG_DAYS); + basis.put("rawLagDate", rawLagDate.toString()); + basis.put("reportingFriday", nominalDtlDate.toString()); + basis.put("dtlAsOnDate", dtlDate.toString()); + basis.put("holdingsAsOnDate", reportingDate.toString()); + basis.put("shiftedForHoliday", shifted); + basis.put("note", "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" + + DTL_LAG_DAYS + " days before the reporting date)" + + (shifted + ? "; that Friday (" + nominalDtlDate + ") was a holiday, so the position is taken at the " + + "preceding working day " + dtlDate + : "") + + "; balances maintained are taken as at " + reportingDate + "."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId); + + final Rate rate = rateInForce("CRR", reportingDate); + response.put("rateType", "CRR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // Inter-bank assets, read on the DTL date rather than the reporting date: they are netted + // off the liability base, so they belong to the same close of business the base is struck + // at. Read on the reporting date they would net a later position against an earlier base. + final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId); + + // Heads the bank added itself - a reserve, a tax payable, anything its chart carries that + // the four built-in roles do not name. Each declares how it is consumed, so they fold into + // the same three totals below without the arithmetic knowing what any of them is called. + final List heads = customHeads("RESERVE", dtlDate, reportingDate, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the + // gross would overstate the requirement by the rate times these balances. + // + // Floored at zero. The subtraction can go negative where inter-bank assets exceed the + // liabilities, and a negative base yields a negative required amount - which would make any + // holding at all, including none, report as a surplus. There is no negative reserve + // requirement: below zero the requirement is nil. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(line("Demand and Time Liabilities (DTL)", dtl, dtlDate)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(line(head.label, head.figure, dtlDate)); + } + } + // The two heads the liability total is built from, each stated on the face of the return as + // RBI reports them rather than left implicit inside the total above. + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate)); + lines.add(line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line("Less: " + head.label, head.figure, dtlDate)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate)); + lines.add(line("Cash in hand — counts toward CRR maintained", cash, reportingDate)); + lines.add(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate)); + for (final CustomHead head : heads) { + // A head belonging to the Memorandum or an Annexure prints under that section's own + // heading below, not here: listing it in both places would show one figure twice and + // invite a reader to add it in. + if (inNamedSection(head)) { + continue; + } + if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure, + reportingDate)); + } + } + response.put("lines", lines); + + final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured); + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED)); + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + // What the bank actually holds, as a percentage of the same base the requirement uses - + // directly comparable with the rate above. + response.put("maintainedPercentage", percentageOf(held, ndtl)); + + // Compliance over the fortnight, not on one day. RBI requires the full requirement on + // average across the fortnight AND a daily minimum on every single day of it, so a bank + // that held nothing for thirteen days and topped up on the last one fails even though + // its closing position looks ample. + final LocalDate fortnightStart = LocalDate.parse((String) response.get("fortnightStart")); + final LocalDate fortnightEnd = LocalDate.parse((String) response.get("fortnightEnd")); + final List maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS")); + maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED)); + + // Only up to the reporting date: days after it have not happened, and averaging an + // unfinished fortnight over its whole length would understate what is held so far. + final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd; + final Map daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles); + + final BigDecimal average = (BigDecimal) daily.get("average"); + final BigDecimal lowest = (BigDecimal) daily.get("lowest"); + final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2, + RoundingMode.HALF_UP); + + daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd)); + daily.put("requiredAverage", required); + daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT); + daily.put("dailyMinimumAmount", dailyFloor); + daily.put("averageMet", average.compareTo(required) >= 0); + daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0); + daily.put("averageShortfall", average.subtract(required)); + response.put("maintenance", daily); + } + // The Memorandum and the two Annexures Form A carries alongside the main statement. They are + // sections of the return rather than extra lines on it, so they are returned separately and + // print under their own headings rather than being mistaken for figures entering the CRR + // arithmetic - which none of them do. + response.put("memorandum", sectionOf(heads, "Memorandum - ")); + response.put("annexureA", sectionOf(heads, "Annexure A - ")); + response.put("annexureB", sectionOf(heads, "Annexure B - ")); + + response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks)); + + return gson.toJson(response); + } + + /** + * The heads belonging to one titled section of the return, with the prefix stripped from each + * label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading. + * + * Selected by label prefix rather than by a column, because a section is a presentational + * grouping: a head moves between sections by being renamed, and an administrator adding a head + * to the Memorandum does so by naming it that way rather than by learning a second concept. + */ + /** Prefixes that move a head out of the main statement and into its own titled section. */ + private static final List SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - "); + + private static boolean inNamedSection(final CustomHead head) { + if (head.label == null) { + return false; + } + return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith); + } + + private static List> sectionOf(final List heads, final String prefix) { + final List> rows = new ArrayList<>(); + for (final CustomHead head : heads) { + if (head.label == null || !head.label.startsWith(prefix)) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("label", head.label.substring(prefix.length())); + row.put("configKey", head.figure.configKey); + row.put("configured", head.figure.configured); + row.put("amount", head.figure.configured ? head.figure.amount : null); + row.put("accounts", head.figure.accounts); + rows.add(row); + } + return rows; + } + + /** + * Form 9 - SLR, filed at the end of the month. + * + * Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as + * at the month end, with no lag, so the return states the position actually held at close. + */ + @GET + @Path("form9") + public String form9(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(reportingDateParam); + final LocalDate monthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9"); + response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)"); + response.put("frequency", "Monthly"); + response.put("requestedDate", requested.toString()); + response.put("reportingDate", monthEnd.toString()); + response.put("monthStart", requested.withDayOfMonth(1).toString()); + + final Map basis = new LinkedHashMap<>(); + basis.put("dtlAsOnDate", monthEnd.toString()); + basis.put("holdingsAsOnDate", monthEnd.toString()); + basis.put("note", "SLR is reported on the month-end position: both the DTL base and the " + + "SLR-eligible investments are taken as at " + monthEnd + "."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId); + final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId); + + final Rate rate = rateInForce("SLR", monthEnd); + response.put("rateType", "SLR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the + // base and the holdings are struck at one close of business. + final List heads = customHeads("RESERVE", monthEnd, monthEnd, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // SLR is charged on the same NET base as CRR. Both sides are month-end here, so the + // inter-bank figure is the one already read above rather than a second read at another date. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(line(head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd)); + lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line("Less: " + head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd)); + lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd)); + // Shown for completeness of the month-end liquid position; not added into SLR maintained. + // A custom MAINTAINED head IS added: it was declared as counting toward the requirement, + // and Form 9's requirement is the SLR one. + lines.add(line("Cash in hand (memo)", cash, monthEnd)); + for (final CustomHead head : heads) { + if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { + // Every such head is a memo on THIS return, including one marked MAINTAINED: only + // SLR-eligible investments count toward SLR, so the label must not suggest the + // figure was added into the maintained total when it was not. + lines.add(line(head.label + " (memo)", head.figure, monthEnd)); + } + } + response.put("lines", lines); + + final boolean computable = rate != null && dtl.configured && investments.configured; + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + // SLR admits government securities, not cash. A head marked MAINTAINED is therefore NOT + // added here the way it is on Form 1: it was declared as counting toward a requirement, + // and on Form 9 only SLR-eligible investments do. Adding it would let a cash-like head + // configured for CRR inflate the SLR position, which is the error this return exists to + // detect. A bank with more eligible securities maps them to SLR_INVESTMENTS. + final BigDecimal held = investments.amount; + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + response.put("maintainedPercentage", percentageOf(held, ndtl)); + } + response.put("unmetRequirements", unmet(rate, "SLR", dtl, investments)); + + return gson.toJson(response); + } + + // ─────────────────────────── computation ─────────────────────────── + + /** A role's balance, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String configKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + /** + * The same total split by liability class, for the roles where RBI reports the two heads + * separately. Both stay zero on asset roles, where the distinction has no meaning. + */ + BigDecimal demandAmount = BigDecimal.ZERO; + BigDecimal timeAmount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + /** Accounts pinned to the role but left out of the total, with why. */ + final List excluded = new ArrayList<>(); + /** + * Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the + * return still adds up, but they cannot be placed under either head - reported so the gap is + * visible rather than showing as a total its own two halves fall short of. + */ + final List unclassified = new ArrayList<>(); + + RoleFigure(final String configKey) { + this.configKey = configKey; + } + } + + /** + * Sums the balances of every GL account pinned to a role, as at a date. + * + * Each account's balance is taken on its own natural side, so a liability role such as DTL + * reports as a positive number rather than as a negative asset-side figure. + * + * A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI + * treats part of a savings balance as a demand liability and the rest as time, so the same + * account is mapped twice under one role - once per class, each with its own weight - and the + * two slices are accumulated under their respective heads. + */ + private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) { + final RoleFigure figure = new RoleFigure(configKey); + + // Every account pinned to the role is read back, usable or not. Excluding the unusable ones + // in SQL would shrink the maintained figure with nothing on the return to say an account had + // dropped out, so they are carried through here and reported by unmet() instead. + // + // Keyed on the mapping row rather than the account: one account can appear more than once + // under a role, and grouping by account would collapse a demand slice and a time slice into + // a single row whose weight is neither. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class, + a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code, c.liability_class + """, configKey); + if (accounts.isEmpty()) { + return figure; + } + figure.configured = true; + + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = (String) account.get("gl_code"); + final String liabilityClass = (String) account.get("liability_class"); + // NOT NULL in the schema, but read defensively: a row that somehow arrived without one + // must not multiply a balance by zero and silently drop it out of the return. + final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED + : (BigDecimal) account.get("weight_percentage"); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into a + // header whose balance is the sum of its children and would double-count them. Either + // way its balance must not silently enter the total - the account is named on the return + // as excluded, so the figure can be read for what it is. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", account.get("name")); + skipped.put("balance", null); + skipped.put("weightPercentage", weight); + skipped.put("liabilityClass", liabilityClass); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final StringBuilder sql = new StringBuilder(""" + SELECT 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 account_id = ? AND 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) + """); + final List args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate))); + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = (BigDecimal) row.get("total_debit"); + final BigDecimal credit = (BigDecimal) row.get("total_credit"); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit); + + // The role takes its configured portion of the balance. Rounded to paise at the slice, + // so the demand and time halves shown on the return are the very numbers that were + // added together - rounding only the total would leave the two heads failing to sum to + // it by a paisa, which on a filed return reads as an error. + final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance + : balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP); + + figure.amount = figure.amount.add(contribution); + if (DEMAND.equals(liabilityClass)) { + figure.demandAmount = figure.demandAmount.add(contribution); + } else if (TIME.equals(liabilityClass)) { + figure.timeAmount = figure.timeAmount.add(contribution); + } else if (LIABILITY_ROLES.contains(configKey)) { + figure.unclassified.add(glCode + " — " + account.get("name")); + } + + final Map detail = new LinkedHashMap<>(); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", account.get("name")); + // Both the account's own balance and the slice taken from it. A reviewer checking a + // weighted line against the ledger needs the figure it was derived from, not only the + // result - otherwise a wrong weight and a wrong balance look identical on the return. + detail.put("accountBalance", balance); + detail.put("weightPercentage", weight); + detail.put("liabilityClass", liabilityClass); + detail.put("balance", contribution); + figure.accounts.add(detail); + } + return figure; + } + + /** An administrator-defined head, with the figure it evaluated to. */ + private static final class CustomHead { + + final String label; + final String usage; + final RoleFigure figure; + + CustomHead(final String label, final String usage, final RoleFigure figure) { + this.label = label; + this.usage = usage; + this.figure = figure; + } + } + + /** + * The heads an administrator added to a return, each evaluated as at the date its treatment + * calls for. + * + * Liabilities are read on the base date and assets on the holdings date, for the same reason the + * built-in roles are: the base is struck at an earlier close of business than the position held + * against it, and reading both at one date would compare figures from different days. + */ + /** + * The keys of administrator-defined heads with one usage - so the daily average counts whatever + * the bank declared as counting toward the requirement, not only the two built-in roles. + */ + private List customKeysWithUsage(final String group, final String usage) { + return jdbcTemplate.queryForList(""" + SELECT config_key FROM m_rbi_reserve_role + WHERE role_group = ? AND role_usage = ? + ORDER BY position, id + """, String.class, group, usage); + } + + private List customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate, + final Long officeId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT config_key, label, role_usage + FROM m_rbi_reserve_role + WHERE role_group = ? + ORDER BY position, label + """, group); + final List heads = new ArrayList<>(); + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + final String usage = (String) row.get("role_usage"); + final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate + : holdingsDate; + final RoleFigure figure = figureFor(key, asOn, officeId); + // A head with nothing mapped is left off the return entirely rather than printed as a + // nil line. It was added but never finished, and an empty row invites the reader to + // treat a gap in configuration as a balance of zero. + if (figure.configured) { + heads.add(new CustomHead((String) row.get("label"), usage, figure)); + } + } + return heads; + } + + /** The total a set of custom heads contributes under one treatment. */ + private static BigDecimal totalOf(final List heads, final String usage) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (head.usage.equals(usage)) { + total = total.add(head.figure.amount); + } + } + return total; + } + + /** The demand or time slice those heads contribute, for the split shown on the face of the return. */ + private static BigDecimal liabilitySliceOf(final List heads, final boolean demand) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount); + } + } + return total; + } + + /** A rate and whether it is still the placeholder the migration seeded. */ + private static final class Rate { + + final BigDecimal percentage; + final boolean seedDefault; + + Rate(final BigDecimal percentage, final boolean seedDefault) { + this.percentage = percentage; + this.seedDefault = seedDefault; + } + } + + /** + * The rate in force on a date - the most recent one effective on or before it. Null when the + * date precedes every rate on record, which stops the return computing rather than defaulting to + * a rate nobody set. + */ + private Rate rateInForce(final String rateType, final LocalDate onDate) { + final List> found = jdbcTemplate.queryForList(""" + SELECT percentage, is_seed_default FROM m_rbi_reserve_rate + WHERE rate_type = ? AND effective_from <= ? + ORDER BY effective_from DESC LIMIT 1 + """, rateType, java.sql.Date.valueOf(onDate)); + if (found.isEmpty()) { + return null; + } + final Map row = found.get(0); + return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default"))); + } + + /** + * The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of + * business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close + * of business to read a balance at. + */ + private static LocalDate reportingFridayOnOrBefore(final LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY)); + } + + /** + * The date itself when the bank was open, otherwise the working day before it. + * + * A reporting Friday that is a declared holiday has no close of business, so there is no balance + * to strike: the statutory framework takes the preceding working day instead. Sundays are + * treated as non-working alongside declared holidays; Saturdays are not, because a reporting + * Friday never falls on one and a holiday shifted back from Friday legitimately lands on + * Thursday. + * + * Bounded at ten days. A chain of holidays longer than that is a data problem rather than a + * calendar, and walking back indefinitely would silently report a balance from weeks earlier. + */ + private LocalDate precedingWorkingDay(final LocalDate date) { + LocalDate candidate = date; + for (int i = 0; i < 10; i++) { + if (!isNonWorkingDay(candidate)) { + return candidate; + } + candidate = candidate.minusDays(1); + } + return date; + } + + private boolean isNonWorkingDay(final LocalDate date) { + if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { + return true; + } + // status_enum 300 is an activated holiday; a draft or deleted one does not close the bank. + final Integer holidays = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_holiday + WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date + """, Integer.class, java.sql.Date.valueOf(date)); + return holidays != null && holidays > 0; + } + + /** + * The average of what was maintained across every day of the fortnight, which is the figure CRR + * compliance actually turns on. + * + * RBI does not read a single day's balance: a bank must keep at least a daily minimum and average + * the full requirement over the fortnight. A return that reported only the reporting date's + * position would call a bank compliant that held nothing for thirteen days and topped up on the + * last one. + * + * Every day is read, including holidays and Sundays: the balance stands over a closed day, and + * excluding those days would let a fortnight ending on a long weekend average away a shortfall. + */ + private Map dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId, + final List maintainedRoles) { + final List> days = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + BigDecimal lowest = null; + LocalDate lowestOn = null; + + for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) { + BigDecimal held = BigDecimal.ZERO; + for (final String role : maintainedRoles) { + held = held.add(figureFor(role, day, officeId).amount); + } + total = total.add(held); + if (lowest == null || held.compareTo(lowest) < 0) { + lowest = held; + lowestOn = day; + } + final Map row = new LinkedHashMap<>(); + row.put("date", day.toString()); + row.put("amount", held); + days.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("days", days); + out.put("dayCount", days.size()); + out.put("average", days.isEmpty() ? BigDecimal.ZERO + : total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP)); + out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest); + out.put("lowestOn", lowestOn == null ? null : lowestOn.toString()); + return out; + } + + /** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */ + private static Map fortnightOf(final LocalDate date) { + final boolean first = date.getDayOfMonth() <= 15; + final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16); + final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth()); + final Map out = new LinkedHashMap<>(); + out.put("fortnight", first ? 1 : 2); + out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight"); + out.put("fortnightStart", start.toString()); + out.put("fortnightEnd", end.toString()); + return out; + } + + /** + * A figure as a percentage of a base, or null where that percentage would not mean anything. + * + * Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction + * and can come out negative where inter-bank assets exceed the liabilities - an unusual position, + * but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that + * reads as a shortfall on a return where holdings are ample, which is worse than showing nothing. + */ + private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) { + if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP); + } + + /** + * A derived row - a split of a role's total, or a figure computed from several roles. It has no + * accounts of its own because it is not read from the ledger: its parts are already listed under + * the lines it was derived from, and repeating them here would show the same balance twice. + */ + private static Map subLine(final String label, final BigDecimal amount, final boolean configured, + final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", null); + out.put("configured", configured); + out.put("asOnDate", asOnDate.toString()); + out.put("amount", configured ? amount : null); + out.put("derived", true); + out.put("accounts", List.of()); + return out; + } + + private static Map line(final String label, final RoleFigure figure, final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", figure.configKey); + out.put("configured", figure.configured); + out.put("asOnDate", asOnDate.toString()); + // Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? figure.amount : null); + out.put("accounts", figure.accounts); + return out; + } + + /** + * Human-readable reasons the return could not be computed, for the screen to show verbatim. + * + * A rate still carrying the seeded placeholder is listed here too. It does not stop the return + * computing - the figures are arithmetically right for that rate - but the rate itself is one + * nobody at the bank confirmed, so the return says so rather than presenting a required amount + * derived from a placeholder as though it were settled. + */ + private static List unmet(final Rate rate, final String rateType, final RoleFigure... figures) { + final List reasons = new ArrayList<>(); + if (rate == null) { + reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. " + + "Set one under Organization > RBI Reserve Configuration."); + } else if (rate.seedDefault) { + reasons.add("The " + rateType + " rate of " + rate.percentage + + "% is the placeholder shipped with this installation, not a rate this bank has confirmed. " + + "Confirm the rate in force under Organization > RBI Reserve Configuration before filing."); + } + for (final RoleFigure figure : figures) { + if (!figure.configured) { + reasons.add("No GL accounts are mapped to " + figure.configKey + + ". Map them under Organization > RBI Reserve Configuration."); + } else if (!figure.unclassified.isEmpty()) { + // Counted in the base but placed under neither head, so the demand and time lines do + // not sum to the total printed above them. On a filed return that reads as an + // arithmetic error, so the accounts responsible are named. + reasons.add(String.join(", ", figure.unclassified) + + " — mapped to the liability base but not marked Demand or Time, so the figure is counted in " + + "NDTL while appearing under neither head. Classify them under Organization > " + + "RBI Reserve Configuration."); + } + if (!figure.excluded.isEmpty()) { + // The figure is short by these accounts' balances. Saying so is the difference + // between a reviewable return and one that under-reports without a trace. + reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded) + + " — mapped but no longer postable, so the balance shown excludes them. " + + "Review the mapping under Organization > RBI Reserve Configuration."); + } + } + return reasons; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java index 9cbe38744..6e60a2d2b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java @@ -227,14 +227,14 @@ public class CurrentProductsApiResource { final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); if (savingProductData.hasAccountingEnabled()) { + // Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever + // savings product shares this id. Payment-channel and per-charge mappings are savings features a current + // product does not write, so none are returned. final Map accountingMappings = accountMappingReadPlatformService - .fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId()); - final Collection paymentChannelToFundSourceMappings = accountMappingReadPlatformService - .fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId); - Collection feeToGLAccountMappings = accountMappingReadPlatformService - .fetchFeeToIncomeAccountMappingsForSavingsProduct(productId); - Collection penaltyToGLAccountMappings = accountMappingReadPlatformService - .fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId); + .fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId()); + final Collection paymentChannelToFundSourceMappings = null; + final Collection feeToGLAccountMappings = null; + final Collection penaltyToGLAccountMappings = null; savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings, paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings); } 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 index afede6a65..9f0a4a43f 100644 --- 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 @@ -36,6 +36,8 @@ import lombok.Getter; @Builder public class DormantAccountData { + /** SAVINGS or CURRENT: which kind of account accountId refers to. */ + private final String accountType; private final Long accountId; private final String accountNo; private final Long clientId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java index 29c751cd6..ef48cf1c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java @@ -23,6 +23,7 @@ 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.savings.service.CurrentAccountDormancyJobService; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.springframework.batch.core.StepContribution; @@ -38,6 +39,7 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { private final SavingsAccountReadPlatformService savingAccountReadPlatformService; private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final CurrentAccountDormancyJobService currentAccountDormancyJobService; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { @@ -63,6 +65,9 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { savingsAccountWritePlatformService.escheat(savingsId); } } + + // Current accounts run the same ladder on their own current product's settings. + currentAccountDormancyJobService.run(tenantLocalDate); return RepeatStatus.FINISHED; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java new file mode 100644 index 000000000..a45768f2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.springframework.stereotype.Service; + +/** + * The cash GL a current account's deposit or withdrawal posts against. + * + *

+ * A current product maps only the customer's liability (Current Control); the cash side is the branch's cash, which + * is the same GL the cashier's Transaction Entry screen uses: the Financial Activity mapping, Cash at Main Vault first + * and Cash at Teller after it -- the order that screen picks in, so the cashier and the ledger always name the same GL. + */ +@Service +@RequiredArgsConstructor +public class CurrentAccountCashGlResolver { + + private final FinancialActivityAccountRepository financialActivityAccountRepository; + + /** The branch cash GL, or null when neither cash activity is mapped. */ + public GLAccount resolve() { + for (final FinancialActivity activity : new FinancialActivity[] { FinancialActivity.CASH_AT_MAINVAULT, + FinancialActivity.CASH_AT_TELLER }) { + final FinancialActivityAccount mapping = financialActivityAccountRepository.findByFinancialActivityType(activity.getValue()); + if (mapping != null && mapping.getGlAccount() != null) { + return mapping.getGlAccount(); + } + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java new file mode 100644 index 000000000..27b395edc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.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.savings.service; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * The dormancy ladder for current accounts, run by the same nightly job as savings. + * + *

+ * Current accounts live in m_current_account, which the savings job never reads, so until now a current account was + * never marked inactive or dormant and never escheated. This walks the same three rungs -- Inactive, Dormant, Escheat + * -- against each account's own current product (m_current_product: is_dormancy_tracking_active and its three day + * counts), measuring idle days with DormancyClock exactly as savings does, so the two kinds of account can never + * disagree about the same customer. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountDormancyJobService { + + private static final int SUB_NONE = 0; + private static final int SUB_INACTIVE = 100; + private static final int SUB_DORMANT = 200; + + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService; + + public void run(final LocalDate businessDate) { + // A re-KYC restarts the clock (DormancyClock), so an account stamped Inactive/Dormant whose idle days are now + // back under the inactive threshold has been reactivated: clear the stamp before climbing the ladder again. + final int reactivated = jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0 WHERE id IN (" + + "SELECT ca.id FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum IN (100, 200) AND cp.days_to_inactive > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " < cp.days_to_inactive)", businessDate); + if (reactivated > 0) { + log.info("[Dormancy] {} current accounts reactivated after re-KYC", reactivated); + } + final int inactive = stamp(businessDate, SUB_NONE, "days_to_inactive", SUB_INACTIVE); + final int dormant = stamp(businessDate, SUB_INACTIVE, "days_to_dormancy", SUB_DORMANT); + int escheated = 0; + for (final Long accountId : pending(businessDate, SUB_DORMANT, "days_to_escheat")) { + try { + if (currentAccountTransactionWritePlatformService.escheat(accountId)) { + escheated++; + } + } catch (RuntimeException e) { + log.error("[Dormancy] Escheat failed for current account {}: {}", accountId, e.getMessage(), e); + } + } + log.info("[Dormancy] Current accounts: {} marked inactive, {} marked dormant, {} escheated", inactive, dormant, escheated); + } + + /** Moves every account on rung {@code from} whose idle days reached {@code daysColumn} up to {@code to}. */ + private int stamp(final LocalDate businessDate, final int from, final String daysColumn, final int to) { + final List ids = pending(businessDate, from, daysColumn); + for (final Long accountId : ids) { + jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = ? WHERE id = ? AND status_enum = 300 AND sub_status_enum = ?", + to, accountId, from); + } + return ids.size(); + } + + private List pending(final LocalDate businessDate, final int subStatus, final String daysColumn) { + final String sql = "SELECT ca.id FROM m_current_account ca" + + " JOIN m_current_product cp ON cp.id = ca.product_id AND cp.is_dormancy_tracking_active = true" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum = ? AND cp." + daysColumn + " > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " >= cp." + daysColumn; + return jdbcTemplate.queryForList(sql, Long.class, subStatus, businessDate); + } +} 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 cbc3c5a63..1e0b19d61 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 @@ -23,12 +23,14 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; 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.service.DateUtils; import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; import org.springframework.jdbc.core.JdbcTemplate; @@ -71,9 +73,61 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead response.put("charges", retrieveCharges(accountId)); response.put("transactions", retrieveTransactions(accountId)); response.put("accountFacilityConfig", retrieveFacilityConfig(accountId)); + addDormancy(accountId, response); return response; } + /** + * The dormancy countdown the account screen's Dormancy Risk Monitor reads, measured exactly as a savings account's + * is: from DormancyClock's last-live date (activation or the latest re-KYC), against the product's thresholds and + * the business date. + * + * The product's dormancyAlert decides whether the screen shows the monitor at all. subStatus is replaced by the + * stage the elapsed days have earned, as the savings read does, because no job stamps current accounts yet; a + * Block stamp (300 and above) is not a dormancy stage and is left alone. + */ + private void addDormancy(final Long accountId, final Map response) { + final String sql = "SELECT cp.dormancy_alert, cp.is_dormancy_tracking_active, cp.days_to_inactive, cp.days_to_dormancy," + + " cp.days_to_escheat, ca.status_enum, ca.sub_status_enum, " + DormancyClock.lastLiveOn("ca") + " AS last_live_on" + + " FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id WHERE ca.id = ?"; + final List> rows = jdbcTemplate.queryForList(sql, accountId); + if (rows.isEmpty()) { + return; + } + final Map row = rows.get(0); + final boolean tracking = toBoolean(row.get("is_dormancy_tracking_active")); + response.put("dormancyAlert", toBoolean(row.get("dormancy_alert"))); + response.put("isDormancyTrackingActive", tracking); + + final LocalDate lastLive = row.get("last_live_on") instanceof java.sql.Date d ? d.toLocalDate() : null; + response.put("lastActiveTransactionDate", lastLive == null ? null : lastLive.toString()); + if (!tracking || lastLive == null || toInt(row.get("status_enum"), 100) != 300) { + return; + } + + final Integer toInactive = row.get("days_to_inactive") == null ? null : toInt(row.get("days_to_inactive"), 0); + final Integer toDormant = row.get("days_to_dormancy") == null ? null : toInt(row.get("days_to_dormancy"), 0); + final Integer toEscheat = row.get("days_to_escheat") == null ? null : toInt(row.get("days_to_escheat"), 0); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final long idle = ChronoUnit.DAYS.between(lastLive, today); + + int subStatus = toInt(row.get("sub_status_enum"), 0); + if (subStatus < 300) { + subStatus = toDormant != null && toDormant > 0 && idle >= toDormant ? 200 + : toInactive != null && toInactive > 0 && idle >= toInactive ? 100 : 0; + response.put("subStatus", buildSubStatus(subStatus)); + } + if (subStatus < 100 && toInactive != null) { + response.put("daysToInactive", ChronoUnit.DAYS.between(today, lastLive.plusDays(toInactive))); + } + if (subStatus < 200 && toDormant != null) { + response.put("daysToDormancy", ChronoUnit.DAYS.between(today, lastLive.plusDays(toDormant))); + } + if (toEscheat != null) { + response.put("daysToEscheat", ChronoUnit.DAYS.between(today, lastLive.plusDays(toEscheat))); + } + } + private Map retrieveFacilityConfig(Long accountId) { final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?"; List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -207,7 +261,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead tt.put("rejectTransfer", false); tt.put("writtenoff", false); tt.put("overdraftFee", false); - tt.put("escheat", false); + tt.put("escheat", typeEnum == 19); tt.put("amountHold", typeEnum == 8); tt.put("amountRelease", typeEnum == 9); tt.put("accrual", false); @@ -219,6 +273,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); } case 8 -> { tt.put("code", "savingsTransactionType.onHold"); tt.put("value", "Amount on hold"); } case 9 -> { tt.put("code", "savingsTransactionType.release"); tt.put("value", "Release Amount"); } + case 19 -> { tt.put("code", "savingsTransactionType.escheat"); tt.put("value", "Escheat"); } default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); } } return tt; @@ -477,6 +532,8 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead out.put("allowOverdraft", row.get("allow_overdraft")); out.put("overdraftLimit", row.get("overdraft_limit")); out.put("minOverdraftForInterestCalculation", row.get("min_overdraft_for_interest_calculation")); + out.put("nominalAnnualInterestRateOverdraft", row.get("nominal_annual_interest_rate_overdraft")); + out.put("validForPeriod", row.get("valid_for_period")); out.put("minRequiredBalance", row.get("min_required_balance")); out.put("enforceMinRequiredBalance", row.get("enforce_min_required_balance")); out.put("onHoldFunds", row.get("on_hold_funds_derived")); @@ -576,12 +633,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead Map s = new LinkedHashMap<>(); s.put("id", subStatusEnum); String value = switch (subStatusEnum) { + // Same codes as savings (SavingsAccountSubStatusEnum), so both kinds of account read alike. case 0 -> "None"; case 100 -> "Inactive"; case 200 -> "Dormant"; - case 300 -> "Block"; - case 400 -> "Block Debit"; + case 300 -> "Escheat"; + case 400 -> "Block"; case 500 -> "Block Credit"; + case 600 -> "Block Debit"; default -> "None"; }; s.put("code", "SavingsAccountSubStatusEnum." + value.replace(" ", "")); @@ -589,9 +648,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead s.put("none", subStatusEnum == 0); s.put("inactive", subStatusEnum == 100); s.put("dormant", subStatusEnum == 200); - s.put("block", subStatusEnum == 300); - s.put("blockDebit", subStatusEnum == 400); + s.put("escheat", subStatusEnum == 300); + s.put("block", subStatusEnum == 400); s.put("blockCredit", subStatusEnum == 500); + s.put("blockDebit", subStatusEnum == 600); return s; } @@ -689,13 +749,15 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead }; } + // SavingsPostingInterestPeriodType codes -- the ones the product form saves and the account copies. private String interestPostingPeriodValue(int id) { return switch (id) { - case 1 -> "Monthly"; - case 2 -> "Quarterly"; - case 3 -> "Biannual"; - case 4 -> "Annually"; - default -> "Annually"; + case 1 -> "Daily"; + case 4 -> "Monthly"; + case 5 -> "Quarterly"; + case 6 -> "Biannual"; + case 7 -> "Annually"; + default -> "Monthly"; }; } 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 c359556d1..3b9ee3487 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 @@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; @@ -45,7 +46,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper; import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @@ -63,6 +66,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; private final JournalEntryRepository journalEntryRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final KycRestrictionGuard kycRestrictionGuard; + private final AppUserRepositoryWrapper appUserRepositoryWrapper; + private final CurrentAccountCashGlResolver cashGlResolver; @Transactional @Override @@ -77,6 +83,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "deposit"); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.CREDIT); this.denominationBreakdownWriter.validatePreviousDayClosed(); this.denominationBreakdownWriter.validateCashierHasMorningTransfer(); this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT"); @@ -123,6 +130,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "withdrawal"); + // A dormant account (its current product's dormancy days passed with no re-KYC) takes no debit + // until the customer is re-verified; the same rule the savings counter applies. + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived()); final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived()); @@ -159,7 +169,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate, "WITHDRAWAL"); - // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out) + // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out) postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal"); return new CommandProcessingResultBuilder() @@ -391,6 +401,59 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren .build(); } + /** + * Escheat, as the savings dormancy ladder does it: the balance goes to the product's escheat liability (Dr Current + * Control, Cr Escheat Liability) and the account is closed with sub-status Escheat (300) by the system user. + * + * Its own transaction, so one account that cannot be escheated never rolls back the rest of the night's batch. + * Refused -- logged, account untouched -- where the move would be wrong or unrecorded: a debit balance is money the + * customer owes, not unclaimed money; a hold is a lien someone else has on the funds; and with cash accounting a + * balance must not leave the account unless the escheat GL exists to receive it. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Override + public boolean escheat(final Long accountId) { + final CurrentAccount account = currentAccountRepository.findById(accountId).orElse(null); + if (account == null || !account.isActive()) { + return false; + } + final BigDecimal balance = safeBalance(account.getAccountBalanceDerived()); + if (balance.signum() < 0 || safeBalance(account.getOnHoldFundsDerived()).signum() > 0) { + log.warn("[Escheat] Current account {} skipped: debit balance {} or funds on hold {}", accountId, balance, + account.getOnHoldFundsDerived()); + return false; + } + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + final boolean accounted = product != null && product.getAccountingType() != null && product.getAccountingType() != 1; + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount escheatAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); + if (accounted && balance.signum() > 0 && (controlAccount == null || escheatAccount == null)) { + log.warn("[Escheat] Current account {} skipped: product {} has no Current Control or Escheat Liability GL mapped", + accountId, account.getProductId()); + return false; + } + + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (balance.signum() > 0) { + final CurrentAccountTransaction txn = CurrentAccountTransaction.escheat(account, account.getOffice(), balance, today); + txn.setRunningBalanceDerived(BigDecimal.ZERO); + currentAccountTransactionRepository.saveAndFlush(txn); + account.setAccountBalanceDerived(BigDecimal.ZERO); + account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(balance)); + if (accounted) { + final String ref = "CAT" + txn.getId(); + final String description = "Current Account Escheat"; + postGlEntry(account, controlAccount, JournalEntryType.DEBIT, balance, today, ref, description); + postGlEntry(account, escheatAccount, JournalEntryType.CREDIT, balance, today, ref, description); + } + } + account.setSubStatusEnum(300); + account.close(today, appUserRepositoryWrapper.fetchSystemUser()); + currentAccountRepository.saveAndFlush(account); + log.info("[Escheat] Current account {} escheated, balance {} moved to escheat liability", accountId, balance); + return true; + } + // ── Helpers ───────────────────────────────────────────────────────────────── private CurrentAccount findActiveAccount(final Long accountId) { @@ -443,37 +506,51 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren } } - // GL: cashEntryType=DEBIT/CREDIT for SAVINGS_REFERENCE; controlEntryType for SAVINGS_CONTROL + /** + * GL: cashEntryType for the branch cash GL, controlEntryType for the account's own product's Current Control. + * + *

+ * Mandatory when the product keeps accounts: a GL that cannot be resolved or posted throws, rolling the whole + * transaction back, so a balance can never move without its journal entry. It used to log and return, and a + * deposit whose Current Control lookup failed moved the balance with no entry at all. + */ private void postTransactionGl(final CurrentAccount account, final CurrentAccountTransaction txn, final JournalEntryType cashEntryType, final JournalEntryType controlEntryType, final String description) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - final Integer accountingType = product.getAccountingType(); - if (accountingType == null || accountingType == 1) return; // NONE = 1 - - final GLAccount cashAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mapping for current account product id={}", account.getProductId()); - return; - } - - final String ref = "CAT" + txn.getId(); - postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - } catch (Exception e) { - log.error("[GL] Failed GL posting for CA transaction id={}: {}", txn.getId(), e.getMessage(), e); + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.product.not.found", + "Current product " + account.getProductId() + " of account " + account.getAccountNo() + " not found.", + account.getId()); } + final Integer accountingType = product.getAccountingType(); + if (accountingType == null || accountingType == 1) { + return; // NONE = 1: the product keeps no accounts + } + + // Cash side: the branch cash GL from the Financial Activity mapping -- the same one the cashier screen uses. + // Liability side: the account's own current product's Current Control, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + if (cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + + final String ref = "CAT" + txn.getId(); + postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); + postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); } private GLAccount findGlAccount(final Long productId, final int financialAccountType) { final var mapping = productToGLAccountMappingRepository .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); return mapping != null ? mapping.getGlAccount() : null; } 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 95216e8e9..0804c7ad7 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 @@ -1,764 +1,783 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * 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.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.RequiredArgsConstructor; -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; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; -import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; -import org.apache.fineract.useradministration.domain.AppUser; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingRuleType; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; -import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { - - private final PlatformSecurityContext context; - private final CurrentAccountRepository currentAccountRepository; - private final CurrentProductRepository currentProductRepository; - private final ClientRepositoryWrapper clientRepository; - private final OfficeRepositoryWrapper officeRepository; - private final CurrentAccountNumberGenerator accountNumberGenerator; - private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; - private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; - private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; - private final JournalEntryRepository journalEntryRepository; - private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; - - @Override - @Transactional - public CommandProcessingResult submitApplication(final JsonCommand command) { - context.authenticatedUser(); - - final JsonObject json = command.parsedJson().getAsJsonObject(); - - final Long clientId = getLong(json, "clientId"); - final Long productId = getLong(json, "productId"); - final Long officeId = getLong(json, "officeId"); - - final Client client = clientRepository.findOneWithNotFoundDetection(clientId); - final CurrentProduct product = currentProductRepository.findById(productId) - .orElseThrow(() -> new SavingsProductNotFoundException(productId)); - - if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.product.for.client", - "Client already has an active Current Account with the same product.", - "productId", productId); - } - final Office office = officeId != null - ? officeRepository.findOneWithNotFoundDetection(officeId) - : client.getOffice(); - - final String dateFormat = getString(json, "dateFormat"); - final String locale = getString(json, "locale"); - - LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); - if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - - // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 - Integer accountTypeEnum = 1; - // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" - String cbsAccountType = null; - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAccountType = json.get("accountType").getAsString(); - try { - accountTypeEnum = Integer.parseInt(rawAccountType); - } catch (Exception ignored) { - // It's a descriptive CBS account type string, not an enum int - cbsAccountType = rawAccountType; - } - } - - boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) - ? json.get("msme").getAsBoolean() : product.isMsme(); - boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); - boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); - - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = CurrentAccount.createNew( - client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, - product.currency().getCode(), product.currency().getDigitsAfterDecimal(), - product.currency().getInMultiplesOf(), - product.nominalAnnualInterestRate(), - product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), - product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), - product.isAllowOverdraft(), product.getOverdraftLimit(), - msme, autoDebitFacility, balanceSwapOption, - product.getCustomerSegment(), product.isAutoPost(), - product.getInterestCalculateSelector(), product.getAveragePeriod(), - product.getAvgBalanceCalcPeriod()); - account.setSubmittedOnByUser(user); - - boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); - account.setShareLinkingEnabled(shareLinkingEnabled); - - // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) - if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); - - // Copy product-level fields that are not in createNew() - account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); - account.setMinRequiredBalance(product.getMinRequiredBalance()); - account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); - account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); - account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); - account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); - account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); - account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); - account.setFrequencyFixDay(product.getFrequencyFixDay()); - account.setEnableLockinPeriod(product.isEnableLockinPeriod()); - account.setBalanceFrequency(product.getBalanceFrequency()); - account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); - account.setValidForPeriod(product.getValidForPeriod()); - account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); - account.setLienAllowed(product.isLienAllowed()); - account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); - account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); - account.setWithholdTax(product.withHoldTax()); - - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); - } - if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { - account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); - } - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - } - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - } - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { - account.setIntroducerName(json.get("introducerName").getAsString()); - } - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - } - // UI may send "introducerClientId" or "introducerId" - Long introducerClientId = getLong(json, "introducerClientId"); - if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); - if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) { - final String extId = json.get("externalId").getAsString().trim(); - account.setExternalId(extId.isEmpty() ? null : extId); - } - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { - account.setAccountStatus(json.get("accountStatus").getAsString()); - } - // Override customerSegment from request if provided - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { - account.setCustomerSegment(json.get("customerSegment").getAsString()); - } - - // Save first to get ID - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) - final String accountNo = accountNumberGenerator.generate(account); - account.updateAccountNo(accountNo); - - // Save nominees if provided in payload (same transaction) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); - } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); - } - - // 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()) { - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // Partners (non-individual) - if (json.has("partners") && json.get("partners").isJsonArray()) { - assemblePartners(account, json.getAsJsonArray("partners")); - } - - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", - account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); - - return new CommandProcessingResultBuilder() - .withEntityId(account.getId()) - .withClientId(clientId) - .withOfficeId(office.getId()) - .build(); - } - - @Override - @Transactional - public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.approve(approvedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.setStatusEnum(100); - account.setApprovedOnDate(null); - account.setApprovedOnByUser(null); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - 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()); - - account.activate(activatedOnDate, user); - - // 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); - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.reject(rejectedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.withdraw(withdrawnOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.close(closedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - - if (account.getStatusEnum() != null) { - if (account.getStatusEnum() == 500) { - account.setStatusEnum(100); - account.setRejectedOnDate(null); - account.setRejectedOnByUser(null); - } else if (account.getStatusEnum() == 400) { - account.setStatusEnum(100); - account.setWithdrawnOnDate(null); - account.setWithdrawnOnByUser(null); - } - } - - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) - account.setExternalId(json.get("externalId").getAsString()); - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) - account.setAccountStatus(json.get("accountStatus").getAsString()); - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAt = json.get("accountType").getAsString(); - try { Integer.parseInt(rawAt); } catch (Exception ignored) { - // descriptive CBS type — save it - account.setCbsAccountType(rawAt); - } - } - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) - account.setCustomerSegment(json.get("customerSegment").getAsString()); - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - if (json.has("msme") && !json.get("msme").isJsonNull()) - account.setMsme(json.get("msme").getAsBoolean()); - if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); - if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); - if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); - if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) - account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) - account.setIntroducerName(json.get("introducerName").getAsString()); - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - - // dates - final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() - ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - try { - account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - try { - account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - - // facility config - JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() - ? json.getAsJsonObject("accountFacilityConfig") : null; - if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) - fcJson = json.getAsJsonObject("facilityConfig"); - if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); - - // joint holders — clear and re-add - if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { - account.getJointHolders().clear(); - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // partners — clear and re-add - if (json.has("partners") && json.get("partners").isJsonArray()) { - account.getPartners().clear(); - assemblePartners(account, json.getAsJsonArray("partners")); - } - - currentAccountRepository.saveAndFlush(account); - - // nominees — replace existing with new (done after flush to ensure accountNo is stable) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.replaceNomineesForAccount( - account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); - } - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult deleteApplication(final Long accountId) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - currentAccountRepository.delete(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.terminate(); - currentAccountRepository.saveAndFlush(account); - - final java.util.Map changes = new java.util.LinkedHashMap<>(); - changes.put("terminateTrue", true); - changes.put("status", 900); - - final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; - final Long clientId = account.getClient() != null ? account.getClient().getId() : null; - - return new CommandProcessingResultBuilder() - .withEntityId(accountId) - .withOfficeId(officeId) - .withClientId(clientId) - .withSavingsId(accountId) - .with(changes) - .build(); - } - - // ─── helpers ────────────────────────────────────────────────────────────── - - private CurrentAccount findAccount(Long accountId) { - return currentAccountRepository.findById(accountId) - .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); - } - - private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { - CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); - if (cfg == null) { - cfg = CurrentAccountFacilityConfig.createFor(account); - } - cfg.setAtmFacility(getBool(fcJson, "atmFacility")); - cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); - cfg.setUpiFacility(getBool(fcJson, "upiFacility")); - cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); - cfg.setChequeBook(getBool(fcJson, "chequeBook")); - cfg.setImpsFacility(getBool(fcJson, "impsFacility")); - cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); - cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); - cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); - cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); - cfg.setNeftFacility(getBool(fcJson, "neftFacility")); - cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); - cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); - cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); - cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); - cfg.setInternetBanking(getBool(fcJson, "internetBanking")); - cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); - return cfg; - } - - private void assembleJointHolders(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); - holder.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) holder.setClientId(cid); - if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); - if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); - if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); - if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getJointHolders().add(holder); - } - } - - private void assemblePartners(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountPartner partner = new CurrentAccountPartner(); - partner.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) partner.setClientId(cid); - if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); - if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getPartners().add(partner); - } - } - - private Long getLong(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - try { - String val = obj.get(key).getAsString().trim(); - if (val.isEmpty()) return null; - return Long.parseLong(val); - } catch (Exception e) { - return null; - } - } - - private String getString(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - String val = obj.get(key).getAsString().trim(); - return val.isEmpty() ? null : val; - } - - private boolean getBool(JsonObject obj, String key) { - return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); - } - - private LocalDate parseDate(String dateStr, String dateFormat, String locale) { - if (dateStr == null || dateStr.isBlank()) return null; - try { - String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, - locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); - } catch (Exception e) { - try { - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); - } catch (Exception ex) { - log.warn("Could not parse date: {}", dateStr); - return null; - } - } - } - - // 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, - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} - - /** - * 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. 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, - java.math.BigDecimal runningBalance, final LocalDate transactionDate, - final java.util.List chargeEntriesOut) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { - if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { - continue; - } - charge.setAmountPaidDerived(charge.getAmount()); - charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); - charge.setPaid(true); - runningBalance = runningBalance.subtract(charge.getAmount()); - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction - .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); - chargeTxn.setRunningBalanceDerived(runningBalance); - currentAccountTransactionRepository.saveAndFlush(chargeTxn); - chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); - log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", - charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); - } - return runningBalance; - } - - @Override - @Transactional - public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { - if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { - return; - } - final CurrentAccount account = findAccount(currentAccountId); - final java.util.List newCharges = new java.util.ArrayList<>(); - - for (final String facilityType : newlyEnabledFacilityTypes) { - final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); - // 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); - } - final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().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); - } - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); - account.getCharges().add(newCharge); - newCharges.add(newCharge); - } - - if (newCharges.isEmpty()) { - return; - } - - java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() - : java.math.BigDecimal.ZERO; - final java.util.List chargeEntries = new java.util.ArrayList<>(); - currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); - - account.setAccountBalanceDerived(currentBalance); - currentAccountRepository.saveAndFlush(account); - - postGlEntries(account, java.util.List.of(), chargeEntries); - } - - // entity type for savings/current accounts in acc_gl_journal_entry - private static final int ENTITY_TYPE_SAVINGS = 2; - - private void postGlEntries(final CurrentAccount account, - final java.util.List depositTxns, - final java.util.List chargeEntries) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - - final Integer accountingRule = product.getAccountingType(); - if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { - log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); - return; - } - - final GLAccount cashAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mappings for product id={} — skipping GL posting", account.getProductId()); - return; - } - - // Deposit uses CA{accountId} as transaction ref (one per activation) - final String depositRef = "CA" + account.getId(); - - // Deposits: DR SAVINGS_REFERENCE (cash in), CR SAVINGS_CONTROL (liability) - if (depositTxns != null) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { - if (txn.getId() == null || txn.getAmount() == null) continue; - postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); - } - } - - // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() - // so each charge appears as a separate transaction in journal entries - if (chargeEntries != null && feeIncomeAccount != null) { - for (final ChargeGlEntry entry : chargeEntries) { - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); - if (txn.getId() == null || txn.getAmount() == null) continue; - final String chargeRef = "CAC" + txn.getId(); - postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); - } - } - } catch (Exception e) { - log.error("[GL] Failed GL posting for current account id={}: {}", account.getId(), e.getMessage(), e); - } - } - - private GLAccount findGlAccount(final Long productId, final int financialAccountType) { - final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = - productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); - return mapping != null ? mapping.getGlAccount() : null; - } - - private void postEntry(final CurrentAccount account, final GLAccount glAccount, - final JournalEntryType entryType, final java.math.BigDecimal amount, - final LocalDate date, final String transactionRef) { - final JournalEntry entry = JournalEntry.createNew( - account.getOffice(), null, glAccount, - account.getCurrencyCode(), transactionRef, - false, date, entryType, amount, - "Current Account activation", - ENTITY_TYPE_SAVINGS, account.getId(), - null, null, null, null, null); - journalEntryRepository.save(entry); - } - - private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { - Throwable cause = e; - while (cause != null) { - final String msg = cause.getMessage(); - if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.externalid", - "Current Account with this External ID already exists.", - "externalId", externalId); - } - cause = cause.getCause(); - } - } - -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * 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.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +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; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingRuleType; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { + + private final PlatformSecurityContext context; + private final CurrentAccountRepository currentAccountRepository; + private final CurrentProductRepository currentProductRepository; + private final ClientRepositoryWrapper clientRepository; + private final OfficeRepositoryWrapper officeRepository; + private final CurrentAccountNumberGenerator accountNumberGenerator; + private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; + private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; + private final CurrentAccountCashGlResolver cashGlResolver; + + @Override + @Transactional + public CommandProcessingResult submitApplication(final JsonCommand command) { + context.authenticatedUser(); + + final JsonObject json = command.parsedJson().getAsJsonObject(); + + final Long clientId = getLong(json, "clientId"); + final Long productId = getLong(json, "productId"); + final Long officeId = getLong(json, "officeId"); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final CurrentProduct product = currentProductRepository.findById(productId) + .orElseThrow(() -> new SavingsProductNotFoundException(productId)); + + if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.product.for.client", + "Client already has an active Current Account with the same product.", + "productId", productId); + } + final Office office = officeId != null + ? officeRepository.findOneWithNotFoundDetection(officeId) + : client.getOffice(); + + final String dateFormat = getString(json, "dateFormat"); + final String locale = getString(json, "locale"); + + LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); + if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 + Integer accountTypeEnum = 1; + // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" + String cbsAccountType = null; + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAccountType = json.get("accountType").getAsString(); + try { + accountTypeEnum = Integer.parseInt(rawAccountType); + } catch (Exception ignored) { + // It's a descriptive CBS account type string, not an enum int + cbsAccountType = rawAccountType; + } + } + + boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) + ? json.get("msme").getAsBoolean() : product.isMsme(); + boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); + boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); + + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = CurrentAccount.createNew( + client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, + product.currency().getCode(), product.currency().getDigitsAfterDecimal(), + product.currency().getInMultiplesOf(), + product.nominalAnnualInterestRate(), + product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), + product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), + product.isAllowOverdraft(), product.getOverdraftLimit(), + msme, autoDebitFacility, balanceSwapOption, + product.getCustomerSegment(), product.isAutoPost(), + product.getInterestCalculateSelector(), product.getAveragePeriod(), + product.getAvgBalanceCalcPeriod()); + account.setSubmittedOnByUser(user); + + boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); + account.setShareLinkingEnabled(shareLinkingEnabled); + + // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) + if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); + + // Copy product-level fields that are not in createNew() + account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); + account.setMinRequiredBalance(product.getMinRequiredBalance()); + account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); + account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); + account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); + account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); + account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); + account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); + account.setFrequencyFixDay(product.getFrequencyFixDay()); + account.setEnableLockinPeriod(product.isEnableLockinPeriod()); + account.setBalanceFrequency(product.getBalanceFrequency()); + account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); + account.setValidForPeriod(product.getValidForPeriod()); + account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); + account.setLienAllowed(product.isLienAllowed()); + account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); + account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); + account.setWithholdTax(product.withHoldTax()); + + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); + } + if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { + account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); + } + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + } + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + } + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { + account.setIntroducerName(json.get("introducerName").getAsString()); + } + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + } + // UI may send "introducerClientId" or "introducerId" + Long introducerClientId = getLong(json, "introducerClientId"); + if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); + if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) { + final String extId = json.get("externalId").getAsString().trim(); + account.setExternalId(extId.isEmpty() ? null : extId); + } + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { + account.setAccountStatus(json.get("accountStatus").getAsString()); + } + // Override customerSegment from request if provided + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { + account.setCustomerSegment(json.get("customerSegment").getAsString()); + } + + // Save first to get ID + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) + final String accountNo = accountNumberGenerator.generate(account); + account.updateAccountNo(accountNo); + + // Save nominees if provided in payload (same transaction) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); + } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); + } + + // 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()) { + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // Partners (non-individual) + if (json.has("partners") && json.get("partners").isJsonArray()) { + assemblePartners(account, json.getAsJsonArray("partners")); + } + + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", + account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); + + return new CommandProcessingResultBuilder() + .withEntityId(account.getId()) + .withClientId(clientId) + .withOfficeId(office.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.approve(approvedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.setStatusEnum(100); + account.setApprovedOnDate(null); + account.setApprovedOnByUser(null); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + 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()); + + account.activate(activatedOnDate, user); + + // 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); + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.reject(rejectedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.withdraw(withdrawnOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.close(closedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + + if (account.getStatusEnum() != null) { + if (account.getStatusEnum() == 500) { + account.setStatusEnum(100); + account.setRejectedOnDate(null); + account.setRejectedOnByUser(null); + } else if (account.getStatusEnum() == 400) { + account.setStatusEnum(100); + account.setWithdrawnOnDate(null); + account.setWithdrawnOnByUser(null); + } + } + + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) + account.setExternalId(json.get("externalId").getAsString()); + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) + account.setAccountStatus(json.get("accountStatus").getAsString()); + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAt = json.get("accountType").getAsString(); + try { Integer.parseInt(rawAt); } catch (Exception ignored) { + // descriptive CBS type — save it + account.setCbsAccountType(rawAt); + } + } + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) + account.setCustomerSegment(json.get("customerSegment").getAsString()); + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + if (json.has("msme") && !json.get("msme").isJsonNull()) + account.setMsme(json.get("msme").getAsBoolean()); + if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); + if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); + if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); + if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) + account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) + account.setIntroducerName(json.get("introducerName").getAsString()); + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + + // dates + final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() + ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + try { + account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + try { + account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + + // facility config + JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() + ? json.getAsJsonObject("accountFacilityConfig") : null; + if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) + fcJson = json.getAsJsonObject("facilityConfig"); + if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); + + // joint holders — clear and re-add + if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { + account.getJointHolders().clear(); + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // partners — clear and re-add + if (json.has("partners") && json.get("partners").isJsonArray()) { + account.getPartners().clear(); + assemblePartners(account, json.getAsJsonArray("partners")); + } + + currentAccountRepository.saveAndFlush(account); + + // nominees — replace existing with new (done after flush to ensure accountNo is stable) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.replaceNomineesForAccount( + account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); + } + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult deleteApplication(final Long accountId) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + currentAccountRepository.delete(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.terminate(); + currentAccountRepository.saveAndFlush(account); + + final java.util.Map changes = new java.util.LinkedHashMap<>(); + changes.put("terminateTrue", true); + changes.put("status", 900); + + final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; + final Long clientId = account.getClient() != null ? account.getClient().getId() : null; + + return new CommandProcessingResultBuilder() + .withEntityId(accountId) + .withOfficeId(officeId) + .withClientId(clientId) + .withSavingsId(accountId) + .with(changes) + .build(); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private CurrentAccount findAccount(Long accountId) { + return currentAccountRepository.findById(accountId) + .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); + } + + private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { + CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); + if (cfg == null) { + cfg = CurrentAccountFacilityConfig.createFor(account); + } + cfg.setAtmFacility(getBool(fcJson, "atmFacility")); + cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); + cfg.setUpiFacility(getBool(fcJson, "upiFacility")); + cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); + cfg.setChequeBook(getBool(fcJson, "chequeBook")); + cfg.setImpsFacility(getBool(fcJson, "impsFacility")); + cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); + cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); + cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); + cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); + cfg.setNeftFacility(getBool(fcJson, "neftFacility")); + cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); + cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); + cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); + cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); + cfg.setInternetBanking(getBool(fcJson, "internetBanking")); + cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); + return cfg; + } + + private void assembleJointHolders(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); + holder.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) holder.setClientId(cid); + if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); + if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); + if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); + if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getJointHolders().add(holder); + } + } + + private void assemblePartners(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountPartner partner = new CurrentAccountPartner(); + partner.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) partner.setClientId(cid); + if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); + if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getPartners().add(partner); + } + } + + private Long getLong(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + String val = obj.get(key).getAsString().trim(); + if (val.isEmpty()) return null; + return Long.parseLong(val); + } catch (Exception e) { + return null; + } + } + + private String getString(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + String val = obj.get(key).getAsString().trim(); + return val.isEmpty() ? null : val; + } + + private boolean getBool(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); + } + + private LocalDate parseDate(String dateStr, String dateFormat, String locale) { + if (dateStr == null || dateStr.isBlank()) return null; + try { + String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, + locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); + } catch (Exception e) { + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); + } catch (Exception ex) { + log.warn("Could not parse date: {}", dateStr); + return null; + } + } + } + + // 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, + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} + + /** + * 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. 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, + java.math.BigDecimal runningBalance, final LocalDate transactionDate, + final java.util.List chargeEntriesOut) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { + if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { + continue; + } + charge.setAmountPaidDerived(charge.getAmount()); + charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); + charge.setPaid(true); + runningBalance = runningBalance.subtract(charge.getAmount()); + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction + .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); + chargeTxn.setRunningBalanceDerived(runningBalance); + currentAccountTransactionRepository.saveAndFlush(chargeTxn); + chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); + log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", + charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); + } + return runningBalance; + } + + @Override + @Transactional + public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { + if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { + return; + } + final CurrentAccount account = findAccount(currentAccountId); + final java.util.List newCharges = new java.util.ArrayList<>(); + + for (final String facilityType : newlyEnabledFacilityTypes) { + final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); + // 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); + } + final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().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); + } + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); + account.getCharges().add(newCharge); + newCharges.add(newCharge); + } + + if (newCharges.isEmpty()) { + return; + } + + java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() + : java.math.BigDecimal.ZERO; + final java.util.List chargeEntries = new java.util.ArrayList<>(); + currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); + + account.setAccountBalanceDerived(currentBalance); + currentAccountRepository.saveAndFlush(account); + + postGlEntries(account, java.util.List.of(), chargeEntries); + } + + // entity type for savings/current accounts in acc_gl_journal_entry + private static final int ENTITY_TYPE_SAVINGS = 2; + + private void postGlEntries(final CurrentAccount account, + final java.util.List depositTxns, + final java.util.List chargeEntries) { + // Mandatory when the product keeps accounts: a GL that is missing or fails to post throws and rolls the charge + // back with it, so the account is never debited without a journal entry. (It used to log and carry on.) + { + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) return; + + final Integer accountingRule = product.getAccountingType(); + if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { + log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); + return; + } + + // Cash comes from the branch cash GL (Financial Activity mapping); the rest from the account's own current + // product, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); + + final boolean hasDeposits = depositTxns != null && !depositTxns.isEmpty(); + final boolean hasCharges = chargeEntries != null && !chargeEntries.isEmpty(); + if (!hasDeposits && !hasCharges) { + return; + } + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasCharges && feeIncomeAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.fee.income.gl.not.mapped", + "Current product '" + product.getName() + "' has no Income From Fees GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasDeposits && cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + + // Deposit uses CA{accountId} as transaction ref (one per activation) + final String depositRef = "CA" + account.getId(); + + // Deposits: DR branch cash GL (cash in), CR SAVINGS_CONTROL (liability) + if (depositTxns != null && cashAccount != null) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { + if (txn.getId() == null || txn.getAmount() == null) continue; + postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); + } + } + + // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() + // so each charge appears as a separate transaction in journal entries + if (chargeEntries != null && feeIncomeAccount != null) { + for (final ChargeGlEntry entry : chargeEntries) { + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); + if (txn.getId() == null || txn.getAmount() == null) continue; + final String chargeRef = "CAC" + txn.getId(); + postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); + } + } + } + } + + private GLAccount findGlAccount(final Long productId, final int financialAccountType) { + final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = + productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); + return mapping != null ? mapping.getGlAccount() : null; + } + + private void postEntry(final CurrentAccount account, final GLAccount glAccount, + final JournalEntryType entryType, final java.math.BigDecimal amount, + final LocalDate date, final String transactionRef) { + final JournalEntry entry = JournalEntry.createNew( + account.getOffice(), null, glAccount, + account.getCurrencyCode(), transactionRef, + false, date, entryType, amount, + "Current Account activation", + ENTITY_TYPE_SAVINGS, account.getId(), + null, null, null, null, null); + journalEntryRepository.save(entry); + } + + private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { + Throwable cause = e; + while (cause != null) { + final String msg = cause.getMessage(); + if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.externalid", + "Current Account with this External ID already exists.", + "externalId", externalId); + } + cause = cause.getCause(); + } + } + +} 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 7a5c235c8..b04776d50 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 @@ -60,13 +60,22 @@ public final class DormancyClock { * "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 = """ - 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 - """; + public static final String LAST_LIVE_ON = lastLiveOn("sa"); + + /** + * The same rule over any account table aliased {@code alias} that has {@code activatedon_date} and + * {@code client_id} — a current account ({@code m_current_account}) is measured exactly as a savings + * account is. + */ + public static String lastLiveOn(final String alias) { + return """ + CASE WHEN %1$s.activatedon_date IS NULL THEN NULL ELSE + GREATEST( + %1$s.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = %1$s.client_id AND k.verified_on IS NOT NULL), + %1$s.activatedon_date)) + END + """.formatted(alias); + } } 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 895dd1cea..31ad20c6b 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 @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -63,6 +64,26 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead public List retrieveDormantAccounts(final Integer subStatus, final String officeName) { this.context.authenticatedUser(); + // Savings and current accounts climb the same ladder (current ones on their own current product's days), so + // the report lists both, longest idle first across the two. + final List accounts = new ArrayList<>(retrieve(subStatus, officeName, false)); + accounts.addAll(retrieve(subStatus, officeName, true)); + accounts.sort(Comparator.comparing(DormantAccountData::getDaysIdle, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(DormantAccountData::getBalance, Comparator.nullsLast(Comparator.reverseOrder()))); + return accounts; + } + + /** + * One kind of account. m_current_account and m_current_product carry the same column names as their savings + * counterparts, so the one query serves both with the tables swapped under the same aliases. DEAF remittances are + * recorded against savings accounts only, so a current account's DEAF columns come back null. + */ + private List retrieve(final Integer subStatus, final String officeName, final boolean current) { + final String accountTable = current ? "m_current_account" : "m_savings_account"; + final String productTable = current ? "m_current_product" : "m_savings_product"; + final String deafJoin = current ? "left join m_deaf_remittance dr on 1 = 0 " + : "left join m_deaf_remittance dr on dr.savings_account_id = sa.id "; + final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON; final StringBuilder sql = new StringBuilder(); @@ -76,13 +97,13 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead 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("from ").append(accountTable).append(" 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 "); + sql.append("inner join ").append(productTable).append(" 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 "); + sql.append(deafJoin); // An account belongs on these reports once the ladder has stamped it, or once the days // have earned it — whichever comes first. // @@ -143,11 +164,17 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead // 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()); + return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(current ? "CURRENT" : "SAVINGS"), params.toArray()); } private static final class DormantAccountMapper implements RowMapper { + private final String accountType; + + DormantAccountMapper(final String accountType) { + this.accountType = accountType; + } + @Override public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { final BigDecimal balance = rs.getBigDecimal("balance"); @@ -164,7 +191,8 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead 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")) + return DormantAccountData.builder().accountType(accountType).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 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 cda677df1..d263de1bc 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 @@ -95,6 +95,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private final VoucherService voucherService; private final FineractRequestContextHolder fineractRequestContextHolder; private final SponsorDDWritePlatformService sponsorDDWritePlatformService; + private final org.apache.fineract.portfolio.account.service.AccountBookDebitService accountBookDebitService; // ── Maker step ───────────────────────────────────────────────────────── @@ -612,17 +613,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst); + // A book debit, not a cash withdrawal: it posts no journal entry of its own, so the one + // DD entry below (Dr control / Cr DD Payable + charges + GST) is the only posting. A cash + // withdrawal posted Dr control / Cr Cash-at-Vault for cash that never moved, which then + // had to be hidden by flagging it reversed. if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; } if (drGlAccountId == null) { @@ -911,17 +914,16 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long linkedAccountId = null; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + // Book debit, no journal entry of its own -- see approveIssueRequest(). if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; } if (drGlAccountId == null) { @@ -1028,9 +1030,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, final BigDecimal ddLimitAmount) { - if (ddPayableGlAccountId == null) { - throw new IllegalArgumentException("Sponsor DD Payable GL account is required."); - } + // DD Payable GL is no longer configured on this screen -- it lives on each sponsor bank + // (m_sponsor_bank.dd_payable_gl_account_id) and is the only source DD posting actually reads + // (see findDdPayableGlAccountIdFor*). The column here is kept nullable for backward + // compatibility but must never be required, or a tenant that has never opened this dialog + // (payable NULL in the row) cannot save the charges/GST/cash mapping at all. final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); final List> existing = this.jdbcTemplate.queryForList( @@ -1055,7 +1059,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "gst_payable_gl_account_id, dd_limit_amount, created_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); - ps.setLong(1, ddPayableGlAccountId); + if (ddPayableGlAccountId != null) ps.setLong(1, ddPayableGlAccountId); else ps.setNull(1, java.sql.Types.BIGINT); if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT); if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT); if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT); @@ -1582,21 +1586,6 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return result.getResourceId().toString(); } - /** - * A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry — - * but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank. - * Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement - * module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD - * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. - */ - private void markWithdrawalGlEntriesReversed(final String 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). */ private Long getSavingsControlGlId(final Long savingsAccountId) { final List> rows = this.jdbcTemplate.queryForList( @@ -1612,7 +1601,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final List> rows = this.jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java new file mode 100644 index 000000000..8b9eccb6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java @@ -0,0 +1,383 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * 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 com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +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.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +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 TDS on deposit interest: the thresholds, rates and statutory + * references for each financial year, and the GL account deducted tax is credited to. + * + * Every TDS screen computes from what this returns. Nothing about the tax is compiled into the + * frontend any more, so a year that is not configured here is a year the screens refuse to compute + * rather than one they compute on stale figures. + */ +@Path("/v1/tdsconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class TdsConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "TDSCONFIG"; + + private static final Pattern FY_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$"); + + private static final List REGIMES = List.of("ITA_1961", "ITA_2025"); + private static final List DECLARATION_FORMS = List.of("15G", "15H", "121"); + private static final List FREQUENCIES = List.of("MONTHLY", "QUARTERLY"); + + /** GL roles, in the order the screen presents them. */ + private static final Map GL_ROLES = new LinkedHashMap<>(); + static { + GL_ROLES.put("TDS_PAYABLE", new String[] { "TDS payable", + "Liability account that tax deducted from deposit interest is credited to, and held in until it is " + + "remitted to the Government." }); + } + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + /** + * Open to any signed-in user, not just holders of READ_TDSCONFIG: a teller's Form 121 tab and the + * branch TDS reports compute from these figures. Only the admin screen is gated on the permission. + */ + @GET + public String retrieveAll() { + this.context.authenticatedUser(); + final Map out = new LinkedHashMap<>(); + out.put("years", years()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + /** Liability detail accounts, the only kind tax payable can sit in. */ + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 AND classification_enum = 2 + ORDER BY gl_code + """); + 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); + } + final Map out = new LinkedHashMap<>(); + out.put("glAccountOptions", options); + out.put("regimes", REGIMES); + out.put("declarationForms", DECLARATION_FORMS); + out.put("frequencies", FREQUENCIES); + return gson.toJson(out); + } + + private List> years() { + final List> rows = jdbcTemplate + .queryForList("SELECT * FROM m_tds_fy_config ORDER BY fy_label DESC"); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("fy", r.get("fy_label")); + o.put("regime", r.get("regime")); + o.put("interestTdsSection", r.get("interest_tds_section")); + o.put("thresholdSection", r.get("threshold_section")); + o.put("noPanSection", r.get("no_pan_section")); + o.put("defaultSection", r.get("default_section")); + o.put("declarationForm", r.get("declaration_form")); + o.put("declarationRule", r.get("declaration_rule")); + o.put("declarationSection", r.get("declaration_section")); + o.put("tdsStatementForm", r.get("tds_statement_form")); + o.put("tdsStatementRule", r.get("tds_statement_rule")); + o.put("certificateForm", r.get("certificate_form")); + o.put("declarationStatementFrequency", r.get("declaration_statement_frequency")); + o.put("declarationStatementDueDay", r.get("declaration_statement_due_day")); + o.put("thresholdGeneral", r.get("threshold_general")); + o.put("thresholdSenior", r.get("threshold_senior")); + o.put("ratePanPercentage", r.get("rate_pan_percentage")); + o.put("rateNoPanPercentage", r.get("rate_no_pan_percentage")); + o.put("basicExemption", r.get("basic_exemption")); + o.put("nilTaxCeiling", r.get("nil_tax_ceiling")); + o.put("roundTo", r.get("round_to")); + o.put("lateDeductionPercentage", r.get("late_deduction_percentage")); + o.put("lateDepositPercentage", r.get("late_deposit_percentage")); + o.put("seedDefault", r.get("is_seed_default")); + o.put("updatedOn", r.get("updated_on") == null ? null : String.valueOf(r.get("updated_on"))); + out.add(o); + } + return out; + } + + /** Every role, including one with nothing mapped - the screen must show the gap. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.config_key, c.gl_account_id, a.name AS gl_account_name, a.gl_code + FROM m_tds_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + """); + final Map> byKey = new LinkedHashMap<>(); + for (final Map r : rows) { + byKey.put((String) r.get("config_key"), r); + } + final List> out = new ArrayList<>(); + for (final Map.Entry role : GL_ROLES.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("label", role.getValue()[0]); + o.put("description", role.getValue()[1]); + final Map mapped = byKey.get(role.getKey()); + o.put("glAccountId", mapped == null ? null : mapped.get("gl_account_id")); + o.put("glCode", mapped == null ? null : mapped.get("gl_code")); + o.put("glAccountName", mapped == null ? null : mapped.get("gl_account_name")); + out.add(o); + } + return out; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Creates or replaces one financial year. The whole row is sent, so a save never leaves a year + * half old and half new. Saving clears the seed flag: the figures are now this bank's own. + */ + @PUT + @Path("year/{fy}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertYear(@PathParam("fy") final String fy, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + validateFy(fy); + + final JsonElement parsed = this.fromJsonHelper.parse(body); + final JsonObject root = parsed.getAsJsonObject(); + + final String regime = oneOf(root, "regime", REGIMES); + final String declarationForm = oneOf(root, "declarationForm", DECLARATION_FORMS); + final String frequency = oneOf(root, "declarationStatementFrequency", FREQUENCIES); + final int dueDay = integer(root, "declarationStatementDueDay", 1, 31); + final int roundTo = integer(root, "roundTo", 1, 100); + + final BigDecimal thresholdGeneral = amount(root, "thresholdGeneral"); + final BigDecimal thresholdSenior = amount(root, "thresholdSenior"); + final BigDecimal basicExemption = amount(root, "basicExemption"); + final BigDecimal nilTaxCeiling = amount(root, "nilTaxCeiling"); + final BigDecimal ratePan = percentage(root, "ratePanPercentage"); + final BigDecimal rateNoPan = percentage(root, "rateNoPanPercentage"); + final BigDecimal lateDeduction = percentage(root, "lateDeductionPercentage"); + final BigDecimal lateDeposit = percentage(root, "lateDepositPercentage"); + + // The no-PAN rate is a penalty rate. Below the ordinary rate it would reward a missing PAN. + if (rateNoPan.compareTo(ratePan) < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.nopan.rate.below.rate", + "The rate without PAN cannot be lower than the rate with PAN."); + } + + final Object[] values = { regime, text(root, "interestTdsSection"), text(root, "thresholdSection"), + text(root, "noPanSection"), text(root, "defaultSection"), declarationForm, text(root, "declarationRule"), + text(root, "declarationSection"), text(root, "tdsStatementForm"), text(root, "tdsStatementRule"), + text(root, "certificateForm"), frequency, dueDay, thresholdGeneral, thresholdSenior, ratePan, rateNoPan, + basicExemption, nilTaxCeiling, roundTo, lateDeduction, lateDeposit, this.context.authenticatedUser().getId(), + fy }; + + final int updated = jdbcTemplate.update(""" + UPDATE m_tds_fy_config + SET regime = ?, interest_tds_section = ?, threshold_section = ?, no_pan_section = ?, + default_section = ?, declaration_form = ?, declaration_rule = ?, declaration_section = ?, + tds_statement_form = ?, tds_statement_rule = ?, certificate_form = ?, + declaration_statement_frequency = ?, declaration_statement_due_day = ?, + threshold_general = ?, threshold_senior = ?, rate_pan_percentage = ?, + rate_no_pan_percentage = ?, basic_exemption = ?, nil_tax_ceiling = ?, round_to = ?, + late_deduction_percentage = ?, late_deposit_percentage = ?, + is_seed_default = false, updated_on = now(), updated_by = ? + WHERE fy_label = ? + """, values); + if (updated == 0) { + jdbcTemplate.update(""" + INSERT INTO m_tds_fy_config + (regime, interest_tds_section, threshold_section, no_pan_section, default_section, + declaration_form, declaration_rule, declaration_section, tds_statement_form, + tds_statement_rule, certificate_form, declaration_statement_frequency, + declaration_statement_due_day, threshold_general, threshold_senior, rate_pan_percentage, + rate_no_pan_percentage, basic_exemption, nil_tax_ceiling, round_to, + late_deduction_percentage, late_deposit_percentage, created_by, fy_label, + is_seed_default, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, now()) + """, values); + } + return retrieveAll(); + } + + /** + * Removes one year. The last year is kept: with none at all every TDS screen stops computing, + * which is a harsher outcome than an administrator deleting the wrong row expects. + */ + @DELETE + @Path("year/{fy}") + public String deleteYear(@PathParam("fy") final String fy) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tds_fy_config", Integer.class); + if (total != null && total <= 1) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.last.year", + "The only financial year on record cannot be removed - edit it instead."); + } + final int removed = jdbcTemplate.update("DELETE FROM m_tds_fy_config WHERE fy_label = ?", fy); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.year.not.found", + "Financial year " + fy + " is not configured."); + } + return retrieveAll(); + } + + /** Sets, or with a null account clears, the GL account behind one role. */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + if (!GL_ROLES.containsKey(configKey)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.unknown.role", + "'" + configKey + "' is not a known TDS GL role."); + } + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + final Long accountId = root.has("glAccountId") && !root.get("glAccountId").isJsonNull() + ? root.get("glAccountId").getAsLong() + : null; + + if (accountId == null) { + jdbcTemplate.update("DELETE FROM m_tds_gl_config WHERE config_key = ?", configKey); + return retrieveAll(); + } + + final Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_gl_account + WHERE id = ? AND disabled = false AND account_usage = 1 AND classification_enum = 2 + """, Integer.class, accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.gl.account.invalid", + "GL account " + accountId + " was not found, is disabled, is a header, or is not a liability account."); + } + final Long userId = this.context.authenticatedUser().getId(); + final int updated = jdbcTemplate.update( + "UPDATE m_tds_gl_config SET gl_account_id = ?, updated_on = now(), updated_by = ? WHERE config_key = ?", + accountId, userId, configKey); + if (updated == 0) { + jdbcTemplate.update( + "INSERT INTO m_tds_gl_config (config_key, gl_account_id, updated_on, updated_by) VALUES (?, ?, now(), ?)", + configKey, accountId, userId); + } + return retrieveAll(); + } + + // ─────────────────────────── validation ─────────────────────────── + + /** '2026-27': a four-digit opening year followed by the next year's last two digits. */ + private static void validateFy(final String fy) { + final Matcher m = fy == null ? null : FY_PATTERN.matcher(fy); + if (m == null || !m.matches() || (Integer.parseInt(m.group(1)) + 1) % 100 != Integer.parseInt(m.group(2))) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.fy.invalid", + "Financial year must look like 2026-27."); + } + } + + private static String text(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull()) { + return null; + } + final String value = root.get(name).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private static String oneOf(final JsonObject root, final String name, final List allowed) { + final String value = text(root, name); + if (value == null || !allowed.contains(value)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", + name + " must be one of " + allowed + "."); + } + return value; + } + + private static BigDecimal number(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull() || root.get(name).getAsString().isBlank()) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".required", name + " is required."); + } + try { + return root.get(name).getAsBigDecimal(); + } catch (NumberFormatException e) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", name + " must be a number."); + } + } + + private static BigDecimal amount(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".negative", name + " cannot be negative."); + } + return value; + } + + private static BigDecimal percentage(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be between 0 and 100."); + } + return value; + } + + private static int integer(final JsonObject root, final String name, final int min, final int max) { + final BigDecimal value = number(root, name); + if (value.stripTrailingZeros().scale() > 0 || value.intValue() < min || value.intValue() > max) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be a whole number between " + min + " and " + max + "."); + } + return value.intValue(); + } +} 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 9813f48f7..b8e38a310 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 @@ -120,4 +120,17 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml new file mode 100644 index 000000000..a9bcd6109 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + 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_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + 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/0572_create_oss_return_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml new file mode 100644 index 000000000..0f630d12a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OSSRETURN' + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code = 'READ_OSSRETURN' + + + + + + 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_OSSRETURN' + 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/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml new file mode 100644 index 000000000..fdcbd37d7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml new file mode 100644 index 000000000..5abce7bf0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_rbi_reserve_gl_config + SET liability_class = 'TIME' + WHERE config_key = 'DEMAND_TIME_LIABILITIES' + AND liability_class IS NULL + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = 'SAVINGS_DEMAND' + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml new file mode 100644 index 000000000..3f436b0c6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role + ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'MEMO')) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml new file mode 100644 index 000000000..c7584302c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml new file mode 100644 index 000000000..95a32eb2d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml new file mode 100644 index 000000000..09bf7ed47 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + 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_CODE', 'READ_CODEVALUE') + AND LOWER(r.name) LIKE '%cashier%' + 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/0579_current_product_gl_mappings_own_product_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml new file mode 100644 index 000000000..aadb1bc9e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id NOT IN (SELECT sp.id FROM m_savings_product sp)) x) + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id IN (SELECT sp.id FROM m_savings_product sp) + AND m.financial_account_type <> 26 + AND m.id > (SELECT MAX(g.min_id) FROM ( + SELECT d.product_id, d.financial_account_type, MIN(d.id) AS min_id + FROM acc_product_mapping d + WHERE d.product_type = 2 AND d.payment_type IS NULL AND d.charge_id IS NULL + GROUP BY d.product_id, d.financial_account_type + HAVING COUNT(*) > 1) g + WHERE g.product_id = m.product_id)) x) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml new file mode 100644 index 000000000..499aa2d59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + SELECT COUNT(*) FROM ( + SELECT 1 FROM acc_product_mapping + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + GROUP BY product_type, product_id, financial_account_type + HAVING COUNT(*) > 1) d + + + + + CREATE UNIQUE INDEX uq_acc_product_mapping_core + ON acc_product_mapping (product_type, product_id, financial_account_type) + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml new file mode 100644 index 000000000..eb135588b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_MEMO_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_ANNEX%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + 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_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + 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/0582_create_tds_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml new file mode 100644 index 000000000..f6e04242e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_tds_fy_config + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + 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_TDSCONFIG', 'UPDATE_TDSCONFIG') + 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-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 772c96b2a..b9dee877d 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 @@ -49,8 +49,10 @@ public class SavingsProductAccountingDataValidator { .notNull().integerGreaterThanZero(); } + // A current product maps no cash GL: its deposits and withdrawals post cash to the branch cash GL + // (Financial Activity mapping), so Savings Reference is validated there only when supplied. if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && !accountType.equals(DepositAccountType.CURRENT_DEPOSIT))) { final Long savingsReferenceAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()) @@ -64,8 +66,11 @@ public class SavingsProductAccountingDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); } + // A current account pays no interest, so its product has no Interest On Savings expense account: + // validated there only when actually supplied. + final boolean paysInterest = !accountType.equals(DepositAccountType.CURRENT_DEPOSIT); if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && paysInterest)) { final Long interestOnSavingsAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue()) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java index 0f3e8922b..1755d4a76 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.savings.domain; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import java.math.BigDecimal; import lombok.Getter; import lombok.Setter; @@ -36,4 +37,19 @@ public class CurrentOnlyEditFields { @Column(name = "od_account_closed_zero_balance_in_day_closing") private Boolean odAccountClosedZeroBalanceInDayClosing; + + @Column(name = "od_min_limit") + private BigDecimal odMinLimit; + + @Column(name = "od_security_type") + private String odSecurityType; + + @Column(name = "od_excess_allowed") + private Boolean odExcessAllowed; + + @Column(name = "od_excess_limit_percent") + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate") + private BigDecimal odPenalRate; } 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 04534c5ac..3043ceff6 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 @@ -771,6 +771,26 @@ public final class ProductPendingEditFieldMapper { f.setOdAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("odAccountClosedZeroBalanceInDayClosing")); present.add("odAccountClosedZeroBalanceInDayClosing"); } + if (command.hasParameter("odMinLimit")) { + f.setOdMinLimit(command.bigDecimalValueOfParameterNamed("odMinLimit")); + present.add("odMinLimit"); + } + if (command.hasParameter("odSecurityType")) { + f.setOdSecurityType(command.stringValueOfParameterNamed("odSecurityType")); + present.add("odSecurityType"); + } + if (command.hasParameter("odExcessAllowed")) { + f.setOdExcessAllowed(command.booleanObjectValueOfParameterNamed("odExcessAllowed")); + present.add("odExcessAllowed"); + } + if (command.hasParameter("odExcessLimitPercent")) { + f.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed("odExcessLimitPercent")); + present.add("odExcessLimitPercent"); + } + if (command.hasParameter("odPenalRate")) { + f.setOdPenalRate(command.bigDecimalValueOfParameterNamed("odPenalRate")); + present.add("odPenalRate"); + } return present; } @@ -781,6 +801,11 @@ public final class ProductPendingEditFieldMapper { putString(root, submitted, "avgBalanceCalcPeriod", f.getAvgBalanceCalcPeriod()); putString(root, submitted, "validForPeriod", f.getValidForPeriod()); putBoolean(root, submitted, "odAccountClosedZeroBalanceInDayClosing", f.getOdAccountClosedZeroBalanceInDayClosing()); + putNumber(root, submitted, "odMinLimit", f.getOdMinLimit()); + putString(root, submitted, "odSecurityType", f.getOdSecurityType()); + putBoolean(root, submitted, "odExcessAllowed", f.getOdExcessAllowed()); + putNumber(root, submitted, "odExcessLimitPercent", f.getOdExcessLimitPercent()); + putNumber(root, submitted, "odPenalRate", f.getOdPenalRate()); } public static Set populateShareOnlyFields(final JsonCommand command, final ShareOnlyEditFields f) { 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 95f04b653..b90805f5e 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 @@ -55,4 +55,5 @@ + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml new file mode 100644 index 000000000..81efbf517 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f696499102747366be04508f2b0015f5ca8b046a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 18:09:13 +0530 Subject: [PATCH 2/3] fix(current-account): stop account-id collisions between savings and current accounts from misrouting money and losing GL entries m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can be a real row in both -- an id-only lookup used to decide "is this a current account?" once sent a real deposit into an unrelated client's current account instead of the savings account a request actually named, across every entry point that did this: cashier deposit approval/reprocess, clerk withdrawal approval, and clerk disbursement collection/disbursement/bank-transfer. All of those now also verify the account_no before trusting an id match. Separately, GL posting for current-account transactions has been silently failing since acc_product_mapping had two rows for the same (product_id, product_type, financial_account_type) slot -- current products share PortfolioProductType.SAVING's id space with real savings products, and a NULL payment_type doesn't collide under that column's own unique constraint, so duplicates went unnoticed. The single-result repository lookup that read this mapping threw on the duplicate, which was swallowed by a try/catch, so account balances kept updating while no journal entry was ever posted. Both current-account GL lookups now take the most recently created mapping instead of failing outright. Co-Authored-By: Claude Sonnet 5 --- .../api/CashierDepositRequestApiResource.java | 43 ++++++++++++++++--- .../ClerkDisbursementRequestApiResource.java | 39 +++++++++++++---- .../ClerkWithdrawalRequestApiResource.java | 21 +++++++-- 3 files changed, 84 insertions(+), 19 deletions(-) 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 25b268f90..592a66d32 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 @@ -423,7 +423,13 @@ public class CashierDepositRequestApiResource { // even for current accounts (submitted before the frontend fix), and FD/RD accounts share // the very same m_savings_account table as a plain savings account, distinguished only by // deposit_type_enum -- so this is looked up fresh rather than trusted from the request. - final String depositCategory = resolveDepositAccountCategory(savingsAccountId); + // The account_no is also checked -- m_current_account and m_savings_account each keep + // their own identity sequence, so the same numeric id can legitimately belong to a + // current account AND an unrelated savings account at once. An id match alone once sent + // a real deposit into a stranger's current account instead of the savings account the + // request actually named; account_no is the one value that can't collide between them. + final String requestAccountNo = (String) record.get("account_no"); + final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo); final boolean isCurrentAccount = "CURRENT".equals(depositCategory); txnId = switch (depositCategory) { case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description); @@ -578,7 +584,8 @@ public class CashierDepositRequestApiResource { final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); - if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) { + final String requestAccountNo = (String) record.get("account_no"); + if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) { throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits."); } @@ -863,11 +870,30 @@ public class CashierDepositRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - /** Returns true if this account ID belongs to m_current_account (not m_savings_account). */ - private boolean isCurrentAccountById(final Long accountId) { + /** + * Returns true if this account ID belongs to m_current_account (not m_savings_account). + * + * m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say) + * can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two + * different clients, sharing nothing but a coincidental number. An id-only check here once sent + * a savings deposit into a stranger's current account: id=1 existed in m_current_account, so + * this returned true, even though the request was for m_savings_account id=1. + * + * expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is + * only trusted once the account_no on that current-account row confirms it is truly the same + * account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller + * has no account_no to compare against, e.g. an id already known to be unambiguous). + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; @@ -880,12 +906,15 @@ public class CashierDepositRequestApiResource { * m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a * plain "not current → savings" check would post an FD/RD deposit through the generic savings * deposit command, which does not accept a term-deposit account id. + * + * expectedAccountNo guards the current-account check against an id collision with + * m_savings_account — see {@link #isCurrentAccountById}. */ - private String resolveDepositAccountCategory(final Long accountId) { + private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) { if (accountId == null) { return "SAVINGS"; } - if (isCurrentAccountById(accountId)) { + if (isCurrentAccountById(accountId, expectedAccountNo)) { return "CURRENT"; } final List depositTypes = jdbcTemplate.queryForList( 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 b8cc344d9..b94837355 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 @@ -551,8 +551,11 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString()); final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?"; final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrent = "CURRENT".equals( - entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank + // transfer posts and moves money immediately, with no maker-checker step to catch + // a wrong label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -660,7 +663,7 @@ public class ClerkDisbursementRequestApiResource { } final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrentAccount = isCurrentAccountById(accountId); + final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo); String txnId = null; if ("Cr".equals(dbCr)) { @@ -959,8 +962,11 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank transfer + // posts and moves money immediately, with no maker-checker step to catch a wrong + // label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -986,8 +992,8 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -1361,10 +1367,25 @@ public class ClerkDisbursementRequestApiResource { commandsSourceWritePlatformService.logCommandSource(cmd); } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here has already sent a + * real deposit into a stranger's current account instead of the savings account the entry + * actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match + * is only trusted once the account_no on that current-account row confirms it. Pass null to skip + * the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; 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 8e2891297..57f9cb070 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 @@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource { String txnId = null; if (savingsAccountId != null) { - final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); + final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) : executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description); @@ -872,10 +872,25 @@ public class ClerkWithdrawalRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here can send a real + * withdrawal against a stranger's current account instead of the savings account the request + * actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id + * match is only trusted once the account_no on that current-account row confirms it. Pass null + * to skip the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; From 32411c924b2f011762d9ac906c0c67227eb81bef Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 10:58:44 +0530 Subject: [PATCH 3/3] fix(accounting): stop the Journal Entry view showing a colliding current account's number for a savings GL entry entity_type_enum=2 (PortfolioProductType.SAVING) is written for both savings and current-account journal entries -- current products deliberately share SAVING's GL type space -- so it can't distinguish them, and m_savings_account/m_current_account each have their own id sequence, meaning an entity_id can be a real row in both. The account- number column's COALESCE(ca.account_no, sa.account_no) always preferred the current account's number whenever both matched, so a savings deposit's own journal entry displayed an unrelated current account's number instead of the account it actually posted against. savings_transaction_id is the one reliable signal available on this query: only a genuine savings transaction sets it. Checking it first resolves the correct account whenever it's present, and falls back to the previous behavior only when it's absent (current-account entries, which don't set it). Co-Authored-By: Claude Sonnet 5 --- .../service/JournalEntryReadPlatformServiceImpl.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 10fe6b8b8..8115f66ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,7 +105,18 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" journalEntry.currency_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(" eao.external_id as externalAssetOwner, ") + // entity_type_enum=2 (PortfolioProductType.SAVING) is written for BOTH savings and + // current-account entries -- current products deliberately share SAVING's GL type + // space (see PortfolioProductType.CURRENT's own comment) -- so it cannot tell the two + // apart on its own, and m_savings_account/m_current_account each have their own id + // sequence: an entity_id can be a real row in both, making a plain + // COALESCE(ca.account_no, sa.account_no) pick the current account's number even for + // an entry that is genuinely a savings one. savings_transaction_id is the one + // unambiguous signal available here -- only a real savings transaction sets it -- so + // it decides first; only when it's absent (a current-account entry, or a savings + // entry with no linked transaction row) does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") .append(" END as savingsAccountNo ");