Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d10ec60f8 | ||
|
|
14973dfd5e | ||
|
|
a127710442 | ||
|
|
919bfb5204 | ||
|
|
39e3fcec59 | ||
|
|
cca4ae583f | ||
|
|
99a7e04872 | ||
|
|
643427bd1a | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
b726577031 | ||
|
|
d1e94fd6a3 | ||
|
|
d7ff4e4a7b | ||
|
|
7068d65422 | ||
|
|
fd827dec27 | ||
|
|
aa264097e7 | ||
|
|
16c67f6701 | ||
|
|
e846892e9f | ||
|
|
56d98bac8d | ||
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 | ||
|
|
22c4403a14 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
|
||||
// Income
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
|
||||
|
||||
+2
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
|
||||
Collection<Teller> findTellerByOfficeId(Long officeId);
|
||||
|
||||
long countByDeletedFalse();
|
||||
|
||||
long countByOfficeIdAndDeletedFalse(Long officeId);
|
||||
}
|
||||
|
||||
+5
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
|
||||
public boolean existsAny() {
|
||||
return this.repository.countByDeletedFalse() > 0;
|
||||
}
|
||||
|
||||
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
|
||||
public boolean existsInOffice(final Long officeId) {
|
||||
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
|
||||
public TellerAlreadyExistsException() {
|
||||
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
public TellerAlreadyExistsException(final String officeName) {
|
||||
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
|
||||
officeName);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ public final class AccountingConstants {
|
||||
INCOME_FROM_INTEREST(12), //
|
||||
LOSSES_WRITTEN_OFF(13), //
|
||||
ESCHEAT_LIABILITY(14), //
|
||||
INTEREST_PAYABLE(17), //
|
||||
/**
|
||||
* Where the paise dropped from a cash closure are absorbed.
|
||||
*
|
||||
|
||||
+23
-10
@@ -137,16 +137,7 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
||||
// auto-approve their own submission just by holding it.
|
||||
if (isApprovedByChecker) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -160,6 +151,28 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "DELINQUENCY_BUCKET".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "DELINQUENCY_RANGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+8
@@ -2299,6 +2299,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createLoanBaseInterestRate() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "LOANBASEINTERESTRATE";
|
||||
this.entityId = null;
|
||||
this.href = "/loanproducts/baseinterestrates";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||
|
||||
+8
-20
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandProcessingService processAndLogCommandService;
|
||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
||||
// from closing this specific self-approval loophole.
|
||||
private final ConfigurationDomainService configurationService;
|
||||
|
||||
@Override
|
||||
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
AppUser appUser = this.context.authenticatedUser();
|
||||
String permissionCode = commandSource.getPermissionCode();
|
||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||
// configuration is left in place and unused for this decision rather than removed, since
|
||||
// deleting a still-configurable global setting is a separate decision from this one).
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
}
|
||||
}
|
||||
return commandSource;
|
||||
}
|
||||
|
||||
+1
@@ -505,6 +505,7 @@ public class FineractProperties {
|
||||
private FineractTransactionProcessorItemProperties heavensFamily;
|
||||
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
|
||||
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
|
||||
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
|
||||
private FineractTransactionProcessorItemProperties rbiIndia;
|
||||
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
|
||||
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ public enum JobName {
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||
|
||||
+14
-1
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
|
||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
|
||||
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
|
||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||
case 500 -> PARTIAL_REINVEST;
|
||||
case 600 -> TRANSFER_TO_GL;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
|
||||
return this.equals(PARTIAL_REINVEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
|
||||
* Only the fixed-deposit premature-closure handler implements it -- see
|
||||
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
|
||||
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
|
||||
* terminal "pay the balance out in cash" branch.
|
||||
*/
|
||||
public boolean isTransferToGl() {
|
||||
return this.equals(TRANSFER_TO_GL);
|
||||
}
|
||||
|
||||
// TODO: do we really need this?!?
|
||||
public static Object[] integerValues() {
|
||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||
|
||||
+16
-1
@@ -212,7 +212,18 @@ public final class DepositsApiConstants {
|
||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||
public static final String transferDescriptionParamName = "transferDescription";
|
||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
|
||||
// credited straight to this GL instead of a savings account or cash.
|
||||
public static final String toGlAccountIdParamName = "toGlAccountId";
|
||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
|
||||
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
|
||||
// per-request flag rather than a stored account field.
|
||||
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
|
||||
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
|
||||
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
|
||||
// penalty and why.
|
||||
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
|
||||
public static final String savingsAccounts = "savingsAccounts";
|
||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||
@@ -241,7 +252,7 @@ public final class DepositsApiConstants {
|
||||
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
|
||||
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
|
||||
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
|
||||
SavingsApiConstants.taxGroupIdParamName));
|
||||
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
|
||||
|
||||
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
|
||||
@@ -379,6 +390,8 @@ public final class DepositsApiConstants {
|
||||
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
fixedDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
fixedDepositRequestData.add("otherGlMappingsMap");
|
||||
return fixedDepositRequestData;
|
||||
}
|
||||
|
||||
@@ -490,6 +503,8 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
recurringDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
recurringDepositRequestData.add("otherGlMappingsMap");
|
||||
return recurringDepositRequestData;
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
|
||||
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
|
||||
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
|
||||
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
|
||||
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
|
||||
* server regardless of what a client happened to also send for the plain waive-penalty flag.
|
||||
*/
|
||||
@Getter
|
||||
public enum PrematureClosureReasonType {
|
||||
|
||||
INVALID(0, "prematureClosureReasonType.invalid", false), //
|
||||
NORMAL(100, "prematureClosureReasonType.normal", false), //
|
||||
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
|
||||
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
|
||||
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
|
||||
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
|
||||
* waive-penalty flag also sent -- see the class javadoc. */
|
||||
private final boolean autoWaived;
|
||||
|
||||
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
|
||||
this.value = value;
|
||||
this.code = code;
|
||||
this.autoWaived = autoWaived;
|
||||
}
|
||||
|
||||
public static PrematureClosureReasonType fromInt(final Integer v) {
|
||||
if (v == null) {
|
||||
return INVALID;
|
||||
}
|
||||
return switch (v) {
|
||||
case 100 -> NORMAL;
|
||||
case 200 -> PREMATURE_EXTENSION;
|
||||
case 300 -> CONVERSION_TO_ANOTHER_TD;
|
||||
case 400 -> DECEASED_SETTLEMENT;
|
||||
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isInvalid() {
|
||||
return this.equals(INVALID);
|
||||
}
|
||||
}
|
||||
+4
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||
break;
|
||||
case TRANSFER_TO_GL:
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
|
||||
break;
|
||||
}
|
||||
return optionData;
|
||||
}
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
|
||||
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
|
||||
|
||||
/**
|
||||
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
|
||||
* then principal.
|
||||
*
|
||||
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
|
||||
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
|
||||
* trying to model that option only the basic one for now.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
|
||||
|
||||
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
|
||||
|
||||
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
|
||||
|
||||
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
|
||||
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
|
||||
super(externalIdFactory, loanChargeValidator, loanBalanceService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCode() {
|
||||
return STRATEGY_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return STRATEGY_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
|
||||
|
||||
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
|
||||
charges);
|
||||
}
|
||||
|
||||
/**
|
||||
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
|
||||
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
|
||||
Set<LoanCharge> charges) {
|
||||
|
||||
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
|
||||
transactionMappings, charges);
|
||||
}
|
||||
|
||||
/**
|
||||
* For normal on-time repayments, pays off interest first, then principal.
|
||||
*/
|
||||
@Override
|
||||
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
|
||||
|
||||
final LocalDate transactionDate = loanTransaction.getTransactionDate();
|
||||
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
|
||||
Money transactionAmountRemaining = transactionAmountUnprocessed;
|
||||
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
|
||||
|
||||
if (loanTransaction.isChargesWaiver()) {
|
||||
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
|
||||
loanTransaction.getPenaltyChargesPortion(currency));
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
|
||||
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
|
||||
loanTransaction.getFeeChargesPortion(currency));
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
|
||||
} else if (loanTransaction.isInterestWaiver()) {
|
||||
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
} else if (loanTransaction.isChargePayment()) {
|
||||
if (loanTransaction.isPenaltyPayment()) {
|
||||
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
} else {
|
||||
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
}
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
} else {
|
||||
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
|
||||
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
|
||||
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
|
||||
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
|
||||
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
}
|
||||
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
|
||||
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
|
||||
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
|
||||
}
|
||||
return transactionAmountRemaining;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
|
||||
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
|
||||
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
|
||||
|
||||
final LocalDate transactionDate = loanTransaction.getTransactionDate();
|
||||
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
|
||||
Money transactionAmountRemaining = transactionAmountUnprocessed;
|
||||
Money principalPortion = Money.zero(currency);
|
||||
Money interestPortion = Money.zero(currency);
|
||||
Money feeChargesPortion = Money.zero(currency);
|
||||
Money penaltyChargesPortion = Money.zero(currency);
|
||||
|
||||
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
|
||||
}
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
|
||||
}
|
||||
|
||||
if (transactionAmountRemaining.isGreaterThanZero()) {
|
||||
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
|
||||
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
|
||||
}
|
||||
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
|
||||
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
|
||||
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
|
||||
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
|
||||
}
|
||||
return transactionAmountRemaining;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanaccount.starter;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
|
||||
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
|
||||
|
||||
@Override
|
||||
protected boolean matches(FineractProperties properties) {
|
||||
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
|
||||
}
|
||||
}
|
||||
+14
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
|
||||
private final LoanProductGuaranteeData productGuaranteeData;
|
||||
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
|
||||
private LoanProductConfigurableAttributes allowAttributeOverrides;
|
||||
|
||||
/**
|
||||
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
|
||||
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
|
||||
*/
|
||||
private Map<String, Object> advanceExtensions;
|
||||
private final boolean syncExpectedWithDisbursementDate;
|
||||
private final boolean isEqualAmortization;
|
||||
private final BigDecimal fixedPrincipalPercentagePerInstallment;
|
||||
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
|
||||
this.allowAttributeOverrides = loanProductConfigurableAttributes;
|
||||
}
|
||||
|
||||
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
|
||||
this.advanceExtensions = advanceExtensions;
|
||||
}
|
||||
|
||||
public Map<String, Object> getAdvanceExtensions() {
|
||||
return this.advanceExtensions;
|
||||
}
|
||||
|
||||
public boolean isIsLinkedToFloatingInterestRates() {
|
||||
return this.isLinkedToFloatingInterestRates;
|
||||
}
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
|
||||
* (security & margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
|
||||
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
|
||||
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
|
||||
* loaded on the hot loan-origination path, so it is deliberately left untouched.
|
||||
*
|
||||
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "m_product_loan_advance_ext")
|
||||
public class LoanProductAdvanceExt {
|
||||
|
||||
@Id
|
||||
@Column(name = "loan_product_id")
|
||||
private Long loanProductId;
|
||||
|
||||
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
|
||||
private LoanProduct loanProduct;
|
||||
|
||||
// ---- classification -------------------------------------------------
|
||||
@Column(name = "product_type_group")
|
||||
private String productTypeGroup;
|
||||
|
||||
@Column(name = "product_type_sub_group")
|
||||
private String productTypeSubGroup;
|
||||
|
||||
@Column(name = "gl_account_id")
|
||||
private Long glAccountId;
|
||||
|
||||
@Column(name = "loan_product_category")
|
||||
private String loanProductCategory;
|
||||
|
||||
@Column(name = "priority_sector")
|
||||
private String prioritySector;
|
||||
|
||||
@Column(name = "cibil_account_type")
|
||||
private String cibilAccountType;
|
||||
|
||||
@Column(name = "kfs_enabled")
|
||||
private Boolean kfsEnabled;
|
||||
|
||||
@Column(name = "apr_enabled")
|
||||
private Boolean aprEnabled;
|
||||
|
||||
// ---- facility -------------------------------------------------------
|
||||
@Column(name = "facility_type")
|
||||
private String facilityType;
|
||||
|
||||
// ---- security & margin ----------------------------------------------
|
||||
@Column(name = "security_type")
|
||||
private String securityType;
|
||||
|
||||
@Column(name = "charge_type")
|
||||
private String chargeType;
|
||||
|
||||
@Column(name = "eligible_amount_basis")
|
||||
private String eligibleAmountBasis;
|
||||
|
||||
@Column(name = "margin_pct")
|
||||
private BigDecimal marginPct;
|
||||
|
||||
@Column(name = "ltv_pct")
|
||||
private BigDecimal ltvPct;
|
||||
|
||||
@Column(name = "dp_method")
|
||||
private String dpMethod;
|
||||
|
||||
// ---- lien -----------------------------------------------------------
|
||||
@Column(name = "lien_required")
|
||||
private Boolean lienRequired;
|
||||
|
||||
@Column(name = "lien_marking_stage")
|
||||
private String lienMarkingStage;
|
||||
|
||||
@Column(name = "lien_certificate_required")
|
||||
private Boolean lienCertificateRequired;
|
||||
|
||||
@Column(name = "lien_certificate_prefix")
|
||||
private String lienCertificatePrefix;
|
||||
|
||||
@Column(name = "print_certificate_on_disbursement")
|
||||
private Boolean printCertificateOnDisbursement;
|
||||
|
||||
// ---- interest (extras; rate bounds live on the core product) --------
|
||||
@Column(name = "interest_basis")
|
||||
private String interestBasis;
|
||||
|
||||
@Column(name = "spread_pct")
|
||||
private BigDecimal spreadPct;
|
||||
|
||||
@Column(name = "interest_charged_on")
|
||||
private String interestChargedOn;
|
||||
|
||||
@Column(name = "interest_posting_frequency")
|
||||
private String interestPostingFrequency;
|
||||
|
||||
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
|
||||
@Column(name = "penal_applicable")
|
||||
private Boolean penalApplicable;
|
||||
|
||||
@Column(name = "penal_rate_pct")
|
||||
private BigDecimal penalRatePct;
|
||||
|
||||
@Column(name = "penal_applied_on")
|
||||
private String penalAppliedOn;
|
||||
|
||||
@Column(name = "penal_grace_days")
|
||||
private Integer penalGraceDays;
|
||||
|
||||
@Column(name = "npa_trigger")
|
||||
private String npaTrigger;
|
||||
|
||||
@Column(name = "interest_after_npa")
|
||||
private String interestAfterNpa;
|
||||
|
||||
@Column(name = "undebited_interest_gl_id")
|
||||
private Long undebitedInterestGlId;
|
||||
|
||||
// ---- renewal & closure ----------------------------------------------
|
||||
@Column(name = "renewal_allowed")
|
||||
private Boolean renewalAllowed;
|
||||
|
||||
@Column(name = "renewal_period_months")
|
||||
private Integer renewalPeriodMonths;
|
||||
|
||||
@Column(name = "renewal_grace_days")
|
||||
private Integer renewalGraceDays;
|
||||
|
||||
@Column(name = "preclosure_allowed")
|
||||
private String preclosureAllowed;
|
||||
|
||||
@Column(name = "lock_in_months")
|
||||
private Integer lockInMonths;
|
||||
|
||||
@Column(name = "interest_on_preclosure")
|
||||
private String interestOnPreclosure;
|
||||
|
||||
// ---- monitoring -----------------------------------------------------
|
||||
@Column(name = "revaluation_freq_months")
|
||||
private Integer revaluationFreqMonths;
|
||||
|
||||
@Column(name = "insurance_required")
|
||||
private Boolean insuranceRequired;
|
||||
|
||||
@Column(name = "stock_statement_freq_months")
|
||||
private Integer stockStatementFreqMonths;
|
||||
|
||||
// ---- cheque book ----------------------------------------------------
|
||||
@Column(name = "cheque_book_enabled")
|
||||
private Boolean chequeBookEnabled;
|
||||
|
||||
@Column(name = "cheque_type")
|
||||
private String chequeType;
|
||||
|
||||
@Column(name = "default_cheque_book_size")
|
||||
private Integer defaultChequeBookSize;
|
||||
|
||||
@Column(name = "pps_threshold")
|
||||
private BigDecimal ppsThreshold;
|
||||
|
||||
@Column(name = "pps_mandatory_above")
|
||||
private BigDecimal ppsMandatoryAbove;
|
||||
|
||||
// ---- share (co-op) --------------------------------------------------
|
||||
@Column(name = "share_capital_gl_id")
|
||||
private Long shareCapitalGlId;
|
||||
|
||||
// ---- security & lien extras -----------------------------------------
|
||||
/** Pledgeable deposit product names, joined with "|". */
|
||||
@Column(name = "eligible_deposit_products")
|
||||
private String eligibleDepositProducts;
|
||||
|
||||
@Column(name = "lien_tenure_not_exceed_maturity")
|
||||
private Boolean lienTenureNotExceedMaturity;
|
||||
|
||||
@Column(name = "lien_auto_release_on_closure")
|
||||
private Boolean lienAutoReleaseOnClosure;
|
||||
|
||||
@Column(name = "lien_allow_multiple_deposits")
|
||||
private Boolean lienAllowMultipleDeposits;
|
||||
|
||||
@Column(name = "lien_adjust_from_maturity")
|
||||
private Boolean lienAdjustFromMaturity;
|
||||
|
||||
@Column(name = "lien_allow_third_party")
|
||||
private Boolean lienAllowThirdParty;
|
||||
|
||||
@Column(name = "lien_allow_renewal_under_lien")
|
||||
private Boolean lienAllowRenewalUnderLien;
|
||||
|
||||
// ---- limit & tenure extras ------------------------------------------
|
||||
@Column(name = "repayment_type")
|
||||
private String repaymentType;
|
||||
|
||||
@Column(name = "appropriation_order")
|
||||
private String appropriationOrder;
|
||||
|
||||
@Column(name = "debit_balance_allow")
|
||||
private Boolean debitBalanceAllow;
|
||||
|
||||
@Column(name = "allow_debit_after_due_date")
|
||||
private Boolean allowDebitAfterDueDate;
|
||||
|
||||
@Column(name = "branch_level_account_type")
|
||||
private Boolean branchLevelAccountType;
|
||||
|
||||
// ---- interest extras ------------------------------------------------
|
||||
@Column(name = "interest_calc_method")
|
||||
private String interestCalcMethod;
|
||||
|
||||
@Column(name = "interest_on_credit_balance")
|
||||
private Boolean interestOnCreditBalance;
|
||||
|
||||
@Column(name = "interest_posting_day")
|
||||
private String interestPostingDay;
|
||||
|
||||
// ---- NPA upgrade rules ----------------------------------------------
|
||||
@Column(name = "npa_upgrade_only_all_arrears")
|
||||
private Boolean npaUpgradeOnlyAllArrears;
|
||||
|
||||
@Column(name = "npa_keep_standard_if_deposit_covers")
|
||||
private Boolean npaKeepStandardIfDepositCovers;
|
||||
|
||||
// ---- cheque book full config ----------------------------------------
|
||||
/** Allowed book sizes (leaves), joined with "|". */
|
||||
@Column(name = "cheque_book_sizes")
|
||||
private String chequeBookSizes;
|
||||
|
||||
@Column(name = "cheque_max_books_outstanding")
|
||||
private Integer chequeMaxBooksOutstanding;
|
||||
|
||||
@Column(name = "cheque_reissue_when_leaves")
|
||||
private Integer chequeReissueWhenLeaves;
|
||||
|
||||
@Column(name = "cheque_first_book_free")
|
||||
private Boolean chequeFirstBookFree;
|
||||
|
||||
@Column(name = "cheque_free_books_per_year")
|
||||
private Integer chequeFreeBooksPerYear;
|
||||
|
||||
@Column(name = "cheque_name_printed")
|
||||
private String chequeNamePrinted;
|
||||
|
||||
@Column(name = "cheque_print_description")
|
||||
private String chequePrintDescription;
|
||||
|
||||
@Column(name = "cheque_stop_payment_allowed")
|
||||
private Boolean chequeStopPaymentAllowed;
|
||||
|
||||
@Column(name = "cheque_maintain_stock_register")
|
||||
private Boolean chequeMaintainStockRegister;
|
||||
|
||||
@Column(name = "cheque_allow_withdrawal_slip")
|
||||
private Boolean chequeAllowWithdrawalSlip;
|
||||
|
||||
@Column(name = "cheque_block_npa")
|
||||
private Boolean chequeBlockNpa;
|
||||
|
||||
@Column(name = "cheque_block_limit_expired")
|
||||
private Boolean chequeBlockLimitExpired;
|
||||
|
||||
@Column(name = "cheque_block_over_dp")
|
||||
private Boolean chequeBlockOverDp;
|
||||
|
||||
@Column(name = "cheque_block_frozen")
|
||||
private Boolean chequeBlockFrozen;
|
||||
|
||||
@Column(name = "cheque_block_lien_released")
|
||||
private Boolean chequeBlockLienReleased;
|
||||
|
||||
// ---- Loans A/C (EMI) wizard fields -------------------------------------
|
||||
@Column(name = "loan_security_mandatory")
|
||||
private String securityMandatory;
|
||||
|
||||
@Column(name = "loan_guarantors_required")
|
||||
private Integer guarantorsRequired;
|
||||
|
||||
@Column(name = "loan_co_applicant")
|
||||
private String coApplicant;
|
||||
|
||||
@Column(name = "loan_third_party_security")
|
||||
private String thirdPartySecurity;
|
||||
|
||||
@Column(name = "loan_legal_opinion_required")
|
||||
private Boolean legalOpinionRequired;
|
||||
|
||||
@Column(name = "loan_property_insurance_required")
|
||||
private Boolean propertyInsuranceRequired;
|
||||
|
||||
@Column(name = "loan_register_charge")
|
||||
private Boolean registerCharge;
|
||||
|
||||
@Column(name = "loan_release_security_on_closure")
|
||||
private Boolean releaseSecurityOnClosure;
|
||||
|
||||
@Column(name = "loan_allow_multiple_securities")
|
||||
private Boolean allowMultipleSecurities;
|
||||
|
||||
@Column(name = "loan_allow_third_party_deposit")
|
||||
private Boolean allowThirdPartyDeposit;
|
||||
|
||||
@Column(name = "loan_min_age")
|
||||
private Integer minAge;
|
||||
|
||||
@Column(name = "loan_max_age_at_maturity")
|
||||
private Integer maxAgeAtMaturity;
|
||||
|
||||
@Column(name = "loan_min_cibil")
|
||||
private Integer minCibil;
|
||||
|
||||
@Column(name = "loan_min_monthly_income")
|
||||
private BigDecimal minMonthlyIncome;
|
||||
|
||||
@Column(name = "loan_max_foir")
|
||||
private BigDecimal maxFoir;
|
||||
|
||||
@Column(name = "loan_max_loans_per_customer")
|
||||
private Integer maxLoansPerCustomer;
|
||||
|
||||
@Column(name = "loan_disbursement_type")
|
||||
private String disbursementType;
|
||||
|
||||
@Column(name = "loan_disbursement_mode")
|
||||
private String disbursementMode;
|
||||
|
||||
@Column(name = "loan_undisbursed_interest")
|
||||
private String undisbursedInterest;
|
||||
|
||||
@Column(name = "loan_pre_emi_interest")
|
||||
private Boolean preEmiInterest;
|
||||
|
||||
@Column(name = "loan_emi_due_day")
|
||||
private String emiDueDay;
|
||||
|
||||
@Column(name = "loan_holiday_rule")
|
||||
private String holidayRule;
|
||||
|
||||
@Column(name = "loan_moratorium_months")
|
||||
private Integer moratoriumMonths;
|
||||
|
||||
@Column(name = "loan_moratorium_interest")
|
||||
private String moratoriumInterest;
|
||||
|
||||
@Column(name = "loan_variable_installments")
|
||||
private Boolean variableInstallments;
|
||||
|
||||
@Column(name = "loan_repayment_mode")
|
||||
private String repaymentMode;
|
||||
|
||||
@Column(name = "loan_linked_account_type")
|
||||
private String linkedAccountType;
|
||||
|
||||
@Column(name = "loan_pdc_collection")
|
||||
private String pdcCollection;
|
||||
|
||||
@Column(name = "loan_mandate_required")
|
||||
private Boolean mandateRequired;
|
||||
|
||||
@Column(name = "loan_advance_emi")
|
||||
private String advanceEmi;
|
||||
|
||||
@Column(name = "loan_rate_type")
|
||||
private String rateType;
|
||||
|
||||
@Column(name = "loan_register_particular")
|
||||
private String registerParticular;
|
||||
|
||||
@Column(name = "loan_apply_cibil_adjustment")
|
||||
private Boolean applyCibilAdjustment;
|
||||
|
||||
@Column(name = "loan_rate_resolved_at")
|
||||
private String rateResolvedAt;
|
||||
|
||||
@Column(name = "loan_calc_period")
|
||||
private String calcPeriod;
|
||||
|
||||
@Column(name = "loan_interest_starts_from")
|
||||
private String interestStartsFrom;
|
||||
|
||||
@Column(name = "loan_compounding")
|
||||
private String compounding;
|
||||
|
||||
@Column(name = "loan_benchmark")
|
||||
private String benchmark;
|
||||
|
||||
@Column(name = "loan_reset_frequency")
|
||||
private String resetFrequency;
|
||||
|
||||
@Column(name = "loan_reset_change")
|
||||
private String resetChange;
|
||||
|
||||
@Column(name = "loan_part_prepayment_allowed")
|
||||
private Boolean partPrepaymentAllowed;
|
||||
|
||||
@Column(name = "loan_min_part_payment")
|
||||
private BigDecimal minPartPayment;
|
||||
|
||||
@Column(name = "loan_allowed_on")
|
||||
private String allowedOn;
|
||||
|
||||
@Column(name = "loan_part_payments_per_year")
|
||||
private String partPaymentsPerYear;
|
||||
|
||||
@Column(name = "loan_part_prepayment_mode")
|
||||
private String partPrepaymentMode;
|
||||
|
||||
@Column(name = "loan_foreclosure_floating_individual")
|
||||
private BigDecimal foreclosureFloatingIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_fixed_individual")
|
||||
private BigDecimal foreclosureFixedIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_non_individual")
|
||||
private BigDecimal foreclosureNonIndividual;
|
||||
|
||||
@Column(name = "loan_foreclosure_takeover")
|
||||
private BigDecimal foreclosureTakeover;
|
||||
|
||||
@Column(name = "loan_foreclosure_quote_valid_days")
|
||||
private Integer foreclosureQuoteValidDays;
|
||||
|
||||
@Column(name = "loan_auto_close_on_zero_balance")
|
||||
private Boolean autoCloseOnZeroBalance;
|
||||
|
||||
@Column(name = "loan_noc_letter")
|
||||
private String nocLetter;
|
||||
|
||||
@Column(name = "loan_restructuring")
|
||||
private String restructuring;
|
||||
|
||||
@Column(name = "loan_release_collateral_on_closure")
|
||||
private Boolean releaseCollateralOnClosure;
|
||||
|
||||
@Column(name = "loan_top_up_allowed")
|
||||
private Boolean topUpAllowed;
|
||||
|
||||
@Column(name = "loan_balance_transfer_out_allowed")
|
||||
private Boolean balanceTransferOutAllowed;
|
||||
|
||||
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
|
||||
this.loanProduct = loanProduct;
|
||||
this.loanProductId = loanProduct.getId();
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
|
||||
|
||||
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%.
|
||||
* Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads
|
||||
* by {@code displayOrder}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_cibil_slab")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterCibilSlab {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "effective_id", nullable = false)
|
||||
private Long effectiveId;
|
||||
|
||||
@Column(name = "score_from", nullable = false)
|
||||
private Integer scoreFrom;
|
||||
|
||||
@Column(name = "score_to", nullable = false)
|
||||
private Integer scoreTo;
|
||||
|
||||
@Column(name = "tier", length = 30, nullable = false)
|
||||
private String tier;
|
||||
|
||||
@Column(name = "slab_type", length = 20, nullable = false)
|
||||
private String slabType;
|
||||
|
||||
@Column(name = "adjustment_pct", nullable = false)
|
||||
private BigDecimal adjustmentPct;
|
||||
|
||||
@Column(name = "display_order", nullable = false)
|
||||
private Integer displayOrder;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier,
|
||||
final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) {
|
||||
final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab();
|
||||
s.effectiveId = effectiveId;
|
||||
s.scoreFrom = scoreFrom;
|
||||
s.scoreTo = scoreTo;
|
||||
s.tier = tier;
|
||||
s.slabType = slabType != null ? slabType : "Flat";
|
||||
s.adjustmentPct = adjustmentPct;
|
||||
s.displayOrder = displayOrder;
|
||||
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterCibilSlabRepository extends JpaRepository<LoanRateRegisterCibilSlab, Long> {
|
||||
|
||||
/** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */
|
||||
List<LoanRateRegisterCibilSlab> findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the
|
||||
* {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with
|
||||
* {@code isActive=true} and deactivates the previous active row ({@code isActive=false,
|
||||
* effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}.
|
||||
*
|
||||
* The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory};
|
||||
* the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's
|
||||
* id and cascade-deleted with it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_effective")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterEffective {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", length = 200, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "effective_from_date", nullable = false)
|
||||
private LocalDate effectiveFromDate;
|
||||
|
||||
@Column(name = "effective_to_date")
|
||||
private LocalDate effectiveToDate;
|
||||
|
||||
@Column(name = "currency_code", length = 3, nullable = false)
|
||||
private String currencyCode;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "fallback_no_cibil_slab", nullable = false)
|
||||
private Integer fallbackNoCibilSlab;
|
||||
|
||||
@Column(name = "fallback_below_min", length = 20, nullable = false)
|
||||
private String fallbackBelowMin;
|
||||
|
||||
@Column(name = "note", length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "created_by_id")
|
||||
private Long createdById;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode,
|
||||
final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) {
|
||||
final LoanRateRegisterEffective e = new LoanRateRegisterEffective();
|
||||
e.name = name;
|
||||
e.effectiveFromDate = effectiveFromDate;
|
||||
e.currencyCode = currencyCode != null ? currencyCode : "INR";
|
||||
e.isActive = true;
|
||||
e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2;
|
||||
e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block";
|
||||
e.note = note;
|
||||
e.createdById = createdById;
|
||||
e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Flip this version to inactive and close its effective window. Called when a newer version is approved. */
|
||||
public void deactivate(final LocalDate toDate) {
|
||||
this.isActive = false;
|
||||
this.effectiveToDate = toDate;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterEffectiveRepository extends JpaRepository<LoanRateRegisterEffective, Long> {
|
||||
|
||||
/** All active versions, newest-effective first. At most one row in a healthy system. */
|
||||
List<LoanRateRegisterEffective> findByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
|
||||
/** The current active version, if any. */
|
||||
Optional<LoanRateRegisterEffective> findFirstByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
|
||||
/** Version history list — all versions, newest first. */
|
||||
List<LoanRateRegisterEffective> findAllByOrderByEffectiveFromDateDesc();
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a
|
||||
* property of the particular, so it lives here rather than on each rate-register version. Kept as a
|
||||
* plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity
|
||||
* graph into the loan module.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_product")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterProduct {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "sr_no", nullable = false)
|
||||
private Integer srNo;
|
||||
|
||||
@Column(name = "name", length = 200, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "gl_account_id")
|
||||
private Long glAccountId;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterProductRepository extends JpaRepository<LoanRateRegisterProduct, Long> {
|
||||
|
||||
/** Active particulars in chart order. */
|
||||
List<LoanRateRegisterProduct> findByIsActiveTrueOrderBySrNoAsc();
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* A single cell of a rate register version — one row per {product × amount-slab}. Product and slab
|
||||
* metadata are denormalised so one query can rebuild the full matrix without a join. The cell holds
|
||||
* either a plain numeric rate, a formula string ({@code "1% + FD"}) resolved at sanction, or
|
||||
* N.A. (both value and formula null, {@code isNa=true}) to mean "slab not offered".
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_loan_rate_register_slab_history")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterSlabHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "effective_id", nullable = false)
|
||||
private Long effectiveId;
|
||||
|
||||
// ---- product dimension ----------------------------------------------
|
||||
/** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */
|
||||
@Column(name = "product_id")
|
||||
private Long productId;
|
||||
|
||||
@Column(name = "product_sr_no", nullable = false)
|
||||
private Integer productSrNo;
|
||||
|
||||
@Column(name = "product_name", length = 200, nullable = false)
|
||||
private String productName;
|
||||
|
||||
// ---- amount-slab dimension ------------------------------------------
|
||||
@Column(name = "slab_order", nullable = false)
|
||||
private Integer slabOrder;
|
||||
|
||||
@Column(name = "slab_label", length = 60, nullable = false)
|
||||
private String slabLabel;
|
||||
|
||||
@Column(name = "amount_upto")
|
||||
private BigDecimal amountUpto;
|
||||
|
||||
// ---- cell value (exactly one of value / formula is populated; both null + isNa=true means N.A.) ----
|
||||
@Column(name = "rate_value")
|
||||
private BigDecimal rateValue;
|
||||
|
||||
@Column(name = "rate_formula", length = 50)
|
||||
private String rateFormula;
|
||||
|
||||
@Column(name = "is_formula", nullable = false)
|
||||
private Boolean isFormula;
|
||||
|
||||
@Column(name = "is_na", nullable = false)
|
||||
private Boolean isNa;
|
||||
|
||||
@Column(name = "created_date")
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo,
|
||||
final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto,
|
||||
final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) {
|
||||
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
|
||||
s.effectiveId = effectiveId;
|
||||
s.productId = productId;
|
||||
s.productSrNo = productSrNo;
|
||||
s.productName = productName;
|
||||
s.slabOrder = slabOrder;
|
||||
s.slabLabel = slabLabel;
|
||||
s.amountUpto = amountUpto;
|
||||
s.rateValue = rateValue;
|
||||
s.rateFormula = rateFormula;
|
||||
s.isFormula = isFormula;
|
||||
s.isNa = isNa;
|
||||
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanRateRegisterSlabHistoryRepository extends JpaRepository<LoanRateRegisterSlabHistory, Long> {
|
||||
|
||||
/** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */
|
||||
List<LoanRateRegisterSlabHistory> findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId);
|
||||
}
|
||||
+45
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
|
||||
"glAccountId", glAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to draw more out of a holding account than it holds.
|
||||
*
|
||||
* <p>
|
||||
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
|
||||
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
|
||||
* liability with a negative balance is not a small number - it says the bank owes less than
|
||||
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
|
||||
* and the figure is small enough to sit unnoticed inside a head that totals positive.
|
||||
*
|
||||
* <p>
|
||||
* Checked here rather than at each call site because the accounts this protects are reached by
|
||||
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
|
||||
* the next one added will not have.
|
||||
*
|
||||
* @param glAccountId the account being drawn on.
|
||||
* @param amount how much the caller wants to take out.
|
||||
* @param what the account's role in the caller's own words, for the message a clerk reads.
|
||||
*/
|
||||
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
|
||||
final java.math.BigDecimal amount, final String what) {
|
||||
if (glAccountId == null || amount == null || amount.signum() <= 0) {
|
||||
return;
|
||||
}
|
||||
// Credits less debits: these are liability-natured holding accounts, so the balance is what
|
||||
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
|
||||
// reversal pair nets to nothing, and counting either side alone moves the balance by the
|
||||
// whole amount.
|
||||
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
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)
|
||||
""", java.math.BigDecimal.class, glAccountId);
|
||||
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
|
||||
if (balance.compareTo(amount) < 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
|
||||
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
|
||||
+ "difference would leave the account with a negative balance, which a "
|
||||
+ "holding account cannot have.",
|
||||
"glAccountId", glAccountId, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+130
-51
@@ -27,6 +27,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||
@@ -42,6 +43,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
|
||||
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.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
|
||||
@@ -82,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AccountingProcessorHelper {
|
||||
|
||||
@@ -661,37 +664,78 @@ public class AccountingProcessorHelper {
|
||||
final GLAccount payableAccount = Optional
|
||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
|
||||
.orElse(controlAccount);
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
|
||||
.orElse(payableAccount);
|
||||
|
||||
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
|
||||
// named Payable account if the product has one, or straight against Savings Control if it
|
||||
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
|
||||
// entries for interest until this leg existed, since a product with no Payable/Paid names
|
||||
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
|
||||
// to post in their place.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
|
||||
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
|
||||
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
|
||||
amount, isReversal);
|
||||
|
||||
// The interest expense is not raised again here. It was just raised above, and this posting
|
||||
// only settles that recognised debt onward to where it is actually paid out from -- raising
|
||||
// the expense a second time would put the same interest on the P&L twice.
|
||||
//
|
||||
// Stage 2: 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);
|
||||
// A product that names no payable falls back to Savings Control above. The chain below would
|
||||
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
|
||||
// reverse, the two cancelling: the expense never charged and the member never credited. Such
|
||||
// a product has no liability to draw down, so it keeps the single pair it always posted.
|
||||
if (payableAccount.getId().equals(controlAccount.getId())) {
|
||||
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
|
||||
payableAccount.getId());
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 3: into the member's own balance.
|
||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
// The interest is charged to expense and raised as a liability first, for whatever part of
|
||||
// it no accrual has already covered.
|
||||
//
|
||||
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
|
||||
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
|
||||
// job never covered - the payable went negative by the interest and the expense netted to
|
||||
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
|
||||
// account cannot hold a negative balance: the bank does not owe less than nothing.
|
||||
//
|
||||
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
|
||||
// payable already carries the interest and this leg writes nothing - the balance below is
|
||||
// enough, and raising it again would double the liability.
|
||||
//
|
||||
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
|
||||
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
|
||||
// debit balance would come back negative and every posting would raise a shortfall larger
|
||||
// than the interest itself.
|
||||
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
|
||||
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
|
||||
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
|
||||
alreadyAccrued, shortfall, payableIsLiability, isReversal);
|
||||
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
|
||||
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
|
||||
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||
shortfall, false);
|
||||
}
|
||||
|
||||
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
|
||||
if (hasSeparatePaidAccount) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
|
||||
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
|
||||
// Stage 3: Out of the interest-paid account, into the member's control account
|
||||
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
} else {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), controlAccount.getName(), amount);
|
||||
// Payout directly from payable into member's control account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -733,34 +777,69 @@ public class AccountingProcessorHelper {
|
||||
*
|
||||
* @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) {
|
||||
/**
|
||||
* What the interest payable account currently holds for this office, on its own liability side.
|
||||
*
|
||||
* <p>
|
||||
* Read so an interest posting can tell an accrual that already ran from one that did not. The
|
||||
* posting chain draws the payable down; where nothing had raised it, the account went negative
|
||||
* and the expense netted to nothing, so the charge never reached the P&L at all. The caller
|
||||
* raises only the shortfall, which is nil when the accrual did run.
|
||||
*
|
||||
* <p>
|
||||
* Credits less debits, because a payable is a liability: the balance is what the bank still
|
||||
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
|
||||
* report, and the caller's arithmetic needs the true figure, not a clamped one.
|
||||
*/
|
||||
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
|
||||
if (payableAccount == null || payableAccount.getId() == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
|
||||
// nothing, and counting either side alone would move the balance by the whole amount.
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
AND office_id = ?
|
||||
AND currency_code = ?
|
||||
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)
|
||||
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
|
||||
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
|
||||
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<Long> 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);
|
||||
for (final String accountName : accountNames) {
|
||||
if (accountName == null) {
|
||||
continue;
|
||||
}
|
||||
final List<Long> 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 lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
|
||||
LIMIT 1
|
||||
""", Long.class, savingsProductId, accountName);
|
||||
if (!found.isEmpty()) {
|
||||
return glAccountRepository.findById(found.get(0)).orElse(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-6
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
|
||||
// cash-based path.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the same helper the cash processor uses, so a posting states
|
||||
// the whole chain the product is configured for -- out of the payable, through
|
||||
// the interest-paid account, into the member's balance -- rather than the single
|
||||
// payable-to-control pair that used to be here, which skipped interest-paid
|
||||
// entirely and left the expense off the P&L.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the payable and paid accounts the product names, so the society
|
||||
// can read interest declared but not yet credited as its own balance. Collapses
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
|
||||
|
||||
@Override
|
||||
public Collection<TellerData> getTellers(Long officeId) {
|
||||
return retrieveAllTellers(false);
|
||||
if (officeId == null) {
|
||||
return retrieveAllTellers(false);
|
||||
}
|
||||
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
|
||||
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
|
||||
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
-4
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
try {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
if (this.tellerRepositoryWrapper.existsAny()) {
|
||||
throw new TellerAlreadyExistsException();
|
||||
}
|
||||
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
|
||||
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
|
||||
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
// final Office parent =
|
||||
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
|
||||
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
|
||||
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
|
||||
// its own cash, so a second branch needs a vault of its own.
|
||||
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
|
||||
throw new TellerAlreadyExistsException(tellerOffice.getName());
|
||||
}
|
||||
final Teller teller = Teller.fromJson(tellerOffice, command);
|
||||
|
||||
// pre save to generate id for use in office hierarchy
|
||||
|
||||
+15
-10
@@ -79,6 +79,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@@ -102,6 +103,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -412,6 +414,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
toSavingsAccount, withdrawal, deposit);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
transferTransactionId = accountTransferDetails.getId();
|
||||
// FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted.
|
||||
this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit,
|
||||
AccountTransferType.fromInt(accountTransferDTO.getTransferType()));
|
||||
|
||||
} else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
|
||||
|
||||
@@ -703,8 +708,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
currentAccountRepository.save(account);
|
||||
if (account.getProductId() != null && txn.getId() != null) {
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
account.getId(), currentTxnId, txnDate, amount);
|
||||
}
|
||||
return txn;
|
||||
@@ -715,7 +720,7 @@ 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 boolean counterpartIsCurrent,
|
||||
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;
|
||||
@@ -735,7 +740,7 @@ 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, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -744,7 +749,7 @@ 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 boolean counterpartIsCurrent,
|
||||
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;
|
||||
@@ -757,7 +762,7 @@ 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, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -803,13 +808,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
if (isWithdrawal) {
|
||||
// DR: current account GL CR: counterpart (savings/current) GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, 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.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
currentTxnId, txnDate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -61,6 +61,7 @@ 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.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -97,12 +98,14 @@ public class AccountConfiguration {
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
|
||||
SavingsInterestTdsService savingsInterestTdsService) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
|
||||
savingsInterestTdsService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+98
-20
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -69,10 +71,26 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class BranchDayRegisterApiResource {
|
||||
|
||||
/**
|
||||
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
|
||||
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
|
||||
* own office. A person moved to another branch's teller keeps what they took at the old branch
|
||||
* there.
|
||||
*/
|
||||
private static final String COUNTER_OFFICE_OF_STD = """
|
||||
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = mc.teller_id
|
||||
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
|
||||
LIMIT 1),
|
||||
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final SavingsInterestPostingReportService interestPostingReportService;
|
||||
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
@@ -104,11 +122,26 @@ public class BranchDayRegisterApiResource {
|
||||
result.put("officeId", officeId);
|
||||
result.put("canOperate", canOperateDay(currentUser));
|
||||
result.put("days", mapDays(rows));
|
||||
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
|
||||
result.put("today", DateUtils.getBusinessLocalDate().toString());
|
||||
result.put("openDay", findOpenDay(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/today ── the bank's "today" ─────────────────────────
|
||||
|
||||
/**
|
||||
* The business date while enable-business-date is on, the tenant's own date while it is off.
|
||||
* Every screen that defaults a date to "today" reads this, rather than the browser clock or the
|
||||
* business-date API (which returns the stored date even when the feature is off). Open to any
|
||||
* signed-in user — cashiers and clerks cannot read the configuration that says which applies.
|
||||
*/
|
||||
@GET
|
||||
@Path("today")
|
||||
public String today() {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString()));
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -121,7 +154,7 @@ public class BranchDayRegisterApiResource {
|
||||
public String missed(@QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
@@ -191,8 +224,8 @@ public class BranchDayRegisterApiResource {
|
||||
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||
// wrong at every office but the first. A row whose officer has no cashier allocation counts
|
||||
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
|
||||
//
|
||||
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||
@@ -201,23 +234,15 @@ public class BranchDayRegisterApiResource {
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
@@ -243,7 +268,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
@@ -437,7 +462,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
|
||||
@@ -589,6 +614,32 @@ public class BranchDayRegisterApiResource {
|
||||
"Business day " + businessDate + " is already closed.");
|
||||
}
|
||||
|
||||
// ── This branch's savings interest reports must be approved before it closes ──
|
||||
// Not overridable by a force-close reason: the branch posts its own customers' interest when
|
||||
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
|
||||
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
if (!interestPending.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
|
||||
"Cannot close the day — this branch's savings interest posting report is not approved for: "
|
||||
+ String.join(", ", interestPending)
|
||||
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
|
||||
}
|
||||
|
||||
// ── The head office closes last ──
|
||||
// Every branch that opened the day closes first and posts its own interest; a branch that
|
||||
// never opened is not waited for.
|
||||
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
||||
if (isHeadOffice) {
|
||||
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
|
||||
// still working and has not posted its interest.
|
||||
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
||||
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
||||
+ ". The head office closes last.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure every cashier holding cash has been reconciled AND settled ──
|
||||
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
|
||||
// and if so, verify reconciliation and settlement records exist for the business date.
|
||||
@@ -687,6 +738,28 @@ public class BranchDayRegisterApiResource {
|
||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||
|
||||
// ── The branch posts its own approved savings interest as it closes ──
|
||||
// The head office also posts what it approved for branches that did not open today. Entries
|
||||
// are raised in the customers' own branch either way.
|
||||
//
|
||||
// Posted here, before the close is written, and synchronously, so the outcome is never
|
||||
// silent: each report posts in a transaction of its own and commits, and if any fails the
|
||||
// close is refused with the reason — the day stays open, the report stays APPROVED, and
|
||||
// closing again once the cause is fixed posts it.
|
||||
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
|
||||
if (isHeadOffice) {
|
||||
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
|
||||
}
|
||||
if (!toPost.isEmpty()) {
|
||||
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
|
||||
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
|
||||
if (!interestFailures.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
|
||||
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
|
||||
+ ". The report is still approved; fix the cause and close the day again.");
|
||||
}
|
||||
}
|
||||
|
||||
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||
@@ -743,7 +816,7 @@ public class BranchDayRegisterApiResource {
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
|
||||
? LocalDate.parse(businessDateParam)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
@@ -1004,6 +1077,9 @@ public class BranchDayRegisterApiResource {
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.get("id"));
|
||||
// The close screen fetches this office's tellers with it; left out, it fetched every teller
|
||||
// the user can see, and the head office counted each branch's vault as its own.
|
||||
m.put("officeId", r.get("office_id"));
|
||||
m.put("businessDate", str(r.get("business_date")));
|
||||
m.put("openedByName", r.get("opened_by_name"));
|
||||
m.put("openedAt", str(r.get("opened_at")));
|
||||
@@ -1057,11 +1133,13 @@ public class BranchDayRegisterApiResource {
|
||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||
|
||||
// By the counter that took the cash. A savings deposit carries no office_id, and the old
|
||||
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
|
||||
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
AND (office_id = ? OR office_id IS NULL)
|
||||
AND COALESCE(counter_office_id, office_id) = ?
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||
|
||||
+27
-12
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
||||
client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource {
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
|
||||
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
|
||||
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
|
||||
*/
|
||||
private Long counterOfficeId(final AppUser user) {
|
||||
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
|
||||
: jdbcTemplate.queryForList("""
|
||||
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE c.staff_id = ? LIMIT 1
|
||||
""", Long.class, user.getStaff().getId());
|
||||
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||
@@ -713,7 +727,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -747,7 +761,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -776,7 +790,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -830,7 +844,7 @@ public class CashierDepositRequestApiResource {
|
||||
return;
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -853,7 +867,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -962,7 +976,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Object paymentTypeId, final String txnDateStr, final String note,
|
||||
final boolean isDeposit) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
|
||||
@@ -1059,8 +1073,9 @@ public class CashierDepositRequestApiResource {
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
// Dated on the request's own date, which is what the branch day's book figure reads.
|
||||
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
|
||||
// drawer on another and leave both days' cash wrong.
|
||||
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||
// drawer on another and leave both days' cash wrong. No date falls back to the
|
||||
// business date for the same reason.
|
||||
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
@@ -1165,7 +1180,7 @@ public class CashierDepositRequestApiResource {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Out: settlements to vault
|
||||
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
|
||||
|
||||
+17
-17
@@ -83,7 +83,7 @@ public class CashReportApiResource {
|
||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
||||
@QueryParam("status") final String status) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
@@ -470,7 +470,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||
@@ -671,25 +671,25 @@ public class CashReportApiResource {
|
||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||
// back. What is left is what should physically be in the drawer.
|
||||
result.put("todayVaultIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todaySettledOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
@@ -721,7 +721,7 @@ public class CashReportApiResource {
|
||||
final Long scopedCashierId) {
|
||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
|
||||
@@ -938,7 +938,7 @@ public class CashReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String today = DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// Find the earliest transfer date before today for which no settlement exists on or after that date
|
||||
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
|
||||
@@ -1001,7 +1001,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-position")
|
||||
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// CASHIER role: always scope to their own position only
|
||||
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
|
||||
@@ -1032,7 +1032,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("cashier-position/me")
|
||||
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
Long appUserId;
|
||||
try {
|
||||
appUserId = context.authenticatedUser().getId();
|
||||
@@ -1070,7 +1070,7 @@ public class CashReportApiResource {
|
||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
||||
@QueryParam("asOfDate") final String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
: DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
if (tellerId == null) {
|
||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
||||
@@ -1092,7 +1092,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-morning")
|
||||
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
if (cashierId == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("cashierId", null);
|
||||
@@ -1171,7 +1171,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Settlements back to vault
|
||||
@@ -1197,7 +1197,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||
@@ -1304,7 +1304,7 @@ public class CashReportApiResource {
|
||||
@QueryParam("offset") Integer offset,
|
||||
@QueryParam("limit") Integer limit) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
|
||||
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
|
||||
|
||||
@@ -1315,13 +1315,13 @@ public class CashReportApiResource {
|
||||
if (cashierId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, cashierId };
|
||||
} else if (tellerId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, tellerId };
|
||||
} else {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
|
||||
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
@@ -226,7 +226,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
glDispatchService.allocateCashToCashier(glTellerId,
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -247,7 +247,7 @@ public class CashSettlementApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
|
||||
Integer.class, cashierId, settlementDate);
|
||||
|
||||
+3
-3
@@ -146,7 +146,7 @@ public class CashTransferApiResource {
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
|
||||
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
@@ -206,7 +206,7 @@ public class CashTransferApiResource {
|
||||
}
|
||||
glDispatchService.settleCashFromCashier(glTellerId,
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -291,7 +291,7 @@ public class CashTransferApiResource {
|
||||
"Vault/teller " + fromTellerId + " was not found.");
|
||||
}
|
||||
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.query("""
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource {
|
||||
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
|
||||
? LocalDate.parse(txnDateStr)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
|
||||
return "{\"result\":\"skipped\"}";
|
||||
|
||||
+5
-5
@@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long resolvedClientId = toLong(dep.get("client_id"));
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
|
||||
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String receiptNo = nextReceiptNo();
|
||||
// What the cashier actually typed at save time, when they typed anything -- that is what
|
||||
// the Apply Share screen's Description column exists to show. Falls back to a plain
|
||||
@@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String voucherNo = (String) req.get("voucher_no");
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
// What the cashier actually typed at save time, when they typed anything -- same preference
|
||||
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
|
||||
@@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
private LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
|
||||
+57
-2
@@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||
// savings is a perfectly valid GL-settlement case.
|
||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||
checks.add(glSettlementMode
|
||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
||||
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
|
||||
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
|
||||
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
|
||||
// and nothing further. Like a GL settlement it never reaches a savings account, so the
|
||||
// savings-side checks below do not apply to it either — running them reports a missing fund
|
||||
// source mapping that no posting in this mode is ever going to read.
|
||||
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
|
||||
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
|
||||
checks.add(noSavingsLeg
|
||||
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
|
||||
? "Not applicable — the redemption rests in Share Sundry"
|
||||
: "Not applicable — settlement is a GL book-transfer")
|
||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
@@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||
} else if (sundryOnlySettlement) {
|
||||
checks.add(checkResult("Settlement destination recorded", true,
|
||||
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
|
||||
} else {
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
@@ -709,6 +722,21 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
||||
}
|
||||
|
||||
// The Share Sundry account the redemption draws on must actually hold the money.
|
||||
//
|
||||
// collectionApplied above says A collection was applied to THIS account, not that the
|
||||
// proceeds are still sitting in Sundry - the same holding account serves every share
|
||||
// account, and an earlier redemption may already have drawn it down. Approving past that
|
||||
// takes Sundry negative, which is a liability owing less than nothing, and the overdraw
|
||||
// lands inside a balance-sheet head that totals positive, where it does not look wrong.
|
||||
final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
|
||||
if (redemptionValue != null) {
|
||||
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
||||
shareAccountId);
|
||||
GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
|
||||
"The Share Sundry (Suspense) account");
|
||||
}
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
@@ -958,6 +986,33 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
|
||||
* reset id sequence in a dev environment can violate.
|
||||
*/
|
||||
/**
|
||||
* What a redemption of this many shares will draw out of Share Sundry.
|
||||
*
|
||||
* <p>
|
||||
* Priced at the unit price the member's own shares were issued at, which is the price the
|
||||
* redemption itself uses. The product's nominal price is not consulted: a member who subscribed
|
||||
* at an earlier price redeems at theirs, and pricing the guard differently from the posting
|
||||
* would either block a redemption that fits or let one through that does not.
|
||||
*
|
||||
* @return the value, or null where no price can be established - the guard then stands aside
|
||||
* rather than blocking on a figure it had to invent.
|
||||
*/
|
||||
private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
|
||||
if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
|
||||
return null;
|
||||
}
|
||||
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||
final BigDecimal unitPrice = jdbcTemplate
|
||||
.queryForList("""
|
||||
SELECT unit_price FROM m_share_account_transactions
|
||||
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", BigDecimal.class, shareAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
|
||||
}
|
||||
|
||||
private Long redeemDebitGlOf(final Long shareTransactionId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
|
||||
+11
-1
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
|
||||
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
||||
+ " AND" + REAL_TXN_ID + ") ";
|
||||
|
||||
/**
|
||||
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
|
||||
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
|
||||
* officer's login, so without this a person moved from one branch's teller to another's carried
|
||||
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
|
||||
* with no recorded creation time keeps its whole history.
|
||||
*/
|
||||
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
|
||||
+ " OR std.created_at >= mc.created_at)";
|
||||
|
||||
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
|
||||
private static String breakdownDiffersFrom(final String amountColumn) {
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
|
||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
|
||||
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
|
||||
}
|
||||
|
||||
+4
-4
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
|
||||
+10
@@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
|
||||
@@ -98,6 +99,15 @@ public class LoanAccountAutoStarter {
|
||||
loanBalanceService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class)
|
||||
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(
|
||||
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
|
||||
final LoanBalanceService loanBalanceService) {
|
||||
return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator,
|
||||
loanBalanceService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class)
|
||||
public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor(
|
||||
|
||||
+57
-2
@@ -37,10 +37,15 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -58,6 +63,7 @@ import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.codes.data.CodeValueData;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiFacingEnum;
|
||||
@@ -107,6 +113,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx
|
||||
import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData;
|
||||
import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
@@ -142,7 +149,8 @@ public class LoanProductsApiResource {
|
||||
LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
|
||||
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME));
|
||||
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
|
||||
"advanceExtensions"));
|
||||
|
||||
private static final Set<String> PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions"));
|
||||
@@ -153,6 +161,7 @@ public class LoanProductsApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanProductReadPlatformService loanProductReadPlatformService;
|
||||
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
|
||||
private final ChargeReadPlatformService chargeReadPlatformService;
|
||||
private final CurrencyReadPlatformService currencyReadPlatformService;
|
||||
private final FundReadPlatformService fundReadPlatformService;
|
||||
@@ -219,11 +228,54 @@ public class LoanProductsApiResource {
|
||||
return this.productMixDataApiJsonSerializer.serialize(settings, productMixes, PRODUCT_MIX_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
final Collection<LoanProductData> products = this.loanProductReadPlatformService.retrieveAllLoanProducts();
|
||||
final Collection<LoanProductData> products = enrichAndFilterProducts(
|
||||
this.loanProductReadPlatformService.retrieveAllLoanProducts(), uriInfo);
|
||||
|
||||
return this.toApiJsonSerializer.serialize(settings, products, LOAN_PRODUCT_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each product's category and type ("Advances A/C" or "Loans A/C" and its sub type) to the list, and applies
|
||||
* the optional filters {@code activeOnly}, {@code productTypeGroup} and {@code productTypeSubGroup}. With none of
|
||||
* them given the list holds the same products as before, each now carrying its classification.
|
||||
*/
|
||||
private Collection<LoanProductData> enrichAndFilterProducts(final Collection<LoanProductData> products, final UriInfo uriInfo) {
|
||||
final MultivaluedMap<String, String> query = uriInfo.getQueryParameters();
|
||||
final boolean activeOnly = Boolean.parseBoolean(query.getFirst("activeOnly"));
|
||||
final String group = trimToNull(query.getFirst("productTypeGroup"));
|
||||
final String subGroup = trimToNull(query.getFirst("productTypeSubGroup"));
|
||||
final Map<Long, Map<String, Object>> extensions = this.loanProductAdvanceExtService.retrieveAllAsMap();
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<LoanProductData> result = new ArrayList<>();
|
||||
for (final LoanProductData product : products) {
|
||||
final Map<String, Object> ext = extensions.getOrDefault(product.getId(), Collections.emptyMap());
|
||||
if (activeOnly && !isActiveOn(product, today)) {
|
||||
continue;
|
||||
}
|
||||
if (group != null && !group.equalsIgnoreCase(String.valueOf(ext.get("productTypeGroup")))) {
|
||||
continue;
|
||||
}
|
||||
if (subGroup != null && !subGroup.equalsIgnoreCase(String.valueOf(ext.get("productTypeSubGroup")))) {
|
||||
continue;
|
||||
}
|
||||
product.setAdvanceExtensions(new LinkedHashMap<>(ext));
|
||||
result.add(product);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A product is active from its start date until its close date; either end may be open. */
|
||||
private static boolean isActiveOn(final LoanProductData product, final LocalDate date) {
|
||||
final LocalDate start = product.getStartDate();
|
||||
final LocalDate close = product.getCloseDate();
|
||||
return (start == null || !start.isAfter(date)) && (close == null || !close.isBefore(date));
|
||||
}
|
||||
|
||||
private static String trimToNull(final String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -384,6 +436,9 @@ public class LoanProductsApiResource {
|
||||
if (settings.isTemplate()) {
|
||||
loanProduct = handleTemplate(loanProduct);
|
||||
}
|
||||
// "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template
|
||||
// steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product.
|
||||
loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId));
|
||||
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* REST surface for the Loan Interest Rate Register. Mirrors
|
||||
* {@code FixedDepositProductsBaseInterestRatesApiResource}: a GET list for the Version History
|
||||
* accordion, a GET /active for the current rate chart, a GET /{id} for one specific version, and
|
||||
* a POST that goes through Fineract's maker-checker pipeline.
|
||||
*
|
||||
* Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded
|
||||
* permissions {@code CREATE_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}.
|
||||
*/
|
||||
@Path("/v1/loanproducts/baseinterestrates")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LoanProductsBaseInterestRatesApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOANPRODUCT";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanRateRegisterReadPlatformService readService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
private final Gson gson = new GsonBuilder()
|
||||
.registerTypeAdapter(LocalDate.class,
|
||||
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
|
||||
.registerTypeAdapter(LocalDateTime.class,
|
||||
(JsonSerializer<LocalDateTime>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)))
|
||||
.create();
|
||||
|
||||
/** Full Version History — all effective versions, newest first. */
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get all Loan Interest Rate Register versions", description = "Returns the full history of rate register versions, newest first.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final List<LoanRateRegisterData> data = this.readService.retrieveAll();
|
||||
return this.gson.toJson(data);
|
||||
}
|
||||
|
||||
/** Master list of particulars (loan types) that make up the chart rows. Literal path wins over {id}. */
|
||||
@GET
|
||||
@Path("products")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get Loan Interest Rate Register particulars", description = "Returns the master list of loan types (chart rows) with their mapped GL account id, in chart order.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveProducts() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return this.gson.toJson(this.readService.retrieveProducts());
|
||||
}
|
||||
|
||||
/** The currently in-force version, with its full chart + CIBIL. */
|
||||
@GET
|
||||
@Path("active")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get the active Loan Interest Rate Register", description = "Returns the version flagged isActive=true, or null when none has been approved yet.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String retrieveActive() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Optional<LoanRateRegisterData> data = this.readService.retrieveActive();
|
||||
return this.gson.toJson(data.orElse(null));
|
||||
}
|
||||
|
||||
/** One specific version by id — used by the Compare screen for the "will be deactivated" baseline. */
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Get a specific rate register version", description = "Returns the full chart + CIBIL bands for the given version id.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "404", description = "Not found") })
|
||||
public String retrieveById(@PathParam("id") final Long id) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Optional<LoanRateRegisterData> data = this.readService.retrieveById(id);
|
||||
return this.gson.toJson(data.orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new version. If {@code CREATE_LOANBASEINTERESTRATE.can_maker_checker=true}, this
|
||||
* returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source}
|
||||
* until a checker approves it from the Approvals tab. Otherwise it commits immediately.
|
||||
*/
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Submit a new Loan Interest Rate Register version", description = "Creates a new version of the loan interest rate register. Deactivates the current active version on approval.")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.createLoanBaseInterestRate()
|
||||
.withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JSON shape expected by {@code POST /loanproducts/baseinterestrates}. Deserialised by Gson in the
|
||||
* write service. One request = one proposed new version of the rate register.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class LoanRateRegisterSaveRequest {
|
||||
|
||||
private String name;
|
||||
/** ISO date "YYYY-MM-DD". Defaults to today when omitted. */
|
||||
private String effectiveFromDate;
|
||||
private String currencyCode;
|
||||
private Integer fallbackNoCibilSlab;
|
||||
private String fallbackBelowMin;
|
||||
private String note;
|
||||
|
||||
/**
|
||||
* Particulars (chart rows) with their GL. A row whose srNo is not yet in the master is created on
|
||||
* approval and must carry a glAccountId; for an existing row a changed glAccountId/name updates the master.
|
||||
*/
|
||||
private List<ProductRequest> products;
|
||||
private List<SlabRequest> slabs;
|
||||
private List<CibilSlabRequest> cibilSlabs;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class ProductRequest {
|
||||
private Integer srNo;
|
||||
private String name;
|
||||
private Long glAccountId;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class SlabRequest {
|
||||
private Integer productSrNo;
|
||||
private String productName;
|
||||
private Integer slabOrder;
|
||||
private String slabLabel;
|
||||
private BigDecimal amountUpto;
|
||||
private BigDecimal rateValue;
|
||||
private String rateFormula;
|
||||
private Boolean isFormula;
|
||||
private Boolean isNa;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public static class CibilSlabRequest {
|
||||
private Integer scoreFrom;
|
||||
private Integer scoreTo;
|
||||
private String tier;
|
||||
private String slabType;
|
||||
private BigDecimal adjustmentPct;
|
||||
private Integer displayOrder;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One CIBIL/credit-score band of a rate register version, used in the response payload.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterCibilSlabData {
|
||||
|
||||
private Long id;
|
||||
private Integer scoreFrom;
|
||||
private Integer scoreTo;
|
||||
private String tier;
|
||||
private String slabType;
|
||||
private BigDecimal adjustmentPct;
|
||||
private Integer displayOrder;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Response DTO for one effective version of the Loan Interest Rate Register. Header fields +
|
||||
* nested chart cells + CIBIL bands.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterData {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private LocalDate effectiveFromDate;
|
||||
private LocalDate effectiveToDate;
|
||||
private String currencyCode;
|
||||
private Boolean isActive;
|
||||
private Integer fallbackNoCibilSlab;
|
||||
private String fallbackBelowMin;
|
||||
private String note;
|
||||
private Long createdById;
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private List<LoanRateRegisterSlabData> slabs;
|
||||
private List<LoanRateRegisterCibilSlabData> cibilSlabs;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Response DTO for one particular (loan type) of the Loan Interest Rate Register. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterProductData {
|
||||
|
||||
private Long id;
|
||||
private Integer srNo;
|
||||
private String name;
|
||||
/** Null until the bank maps the particular to a GL account. */
|
||||
private Long glAccountId;
|
||||
private Boolean isActive;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* One cell of a rate register version — same shape as the entity but a POJO for the response.
|
||||
* Serialised through Gson in the REST resource.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class LoanRateRegisterSlabData {
|
||||
|
||||
private Long id;
|
||||
private Long productId;
|
||||
private Integer productSrNo;
|
||||
private String productName;
|
||||
private Integer slabOrder;
|
||||
private String slabLabel;
|
||||
private BigDecimal amountUpto;
|
||||
private BigDecimal rateValue;
|
||||
private String rateFormula;
|
||||
private Boolean isFormula;
|
||||
private Boolean isNa;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Dispatched by Fineract when a {@code LOANBASEINTERESTRATE} / {@code CREATE} command is executed —
|
||||
* on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType}
|
||||
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createLoanBaseInterestRate()}
|
||||
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
|
||||
*/
|
||||
@Service
|
||||
@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE")
|
||||
@RequiredArgsConstructor
|
||||
public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final LoanRateRegisterWritePlatformService writePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.saveRateRegister(command);
|
||||
}
|
||||
}
|
||||
+8
@@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
|
||||
@@ -212,6 +213,13 @@ public final class LoanProductDataValidator {
|
||||
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
|
||||
));
|
||||
|
||||
static {
|
||||
// "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the
|
||||
// authoritative list of their names — added here rather than restated so the two cannot drift, and so
|
||||
// the unsupported-parameter check keeps rejecting genuine typos.
|
||||
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS));
|
||||
}
|
||||
|
||||
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
|
||||
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
|
||||
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.serialization;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Validates a {@link LoanRateRegisterSaveRequest} before the write service touches the DB. Keeps
|
||||
* the write service free of guard clauses and surfaces every error up front rather than failing on
|
||||
* the first one — Fineract convention.
|
||||
*/
|
||||
@Component
|
||||
public class LoanRateRegisterDataValidator {
|
||||
|
||||
private static final String RESOURCE_NAME = "loanRateRegister";
|
||||
|
||||
public void validate(final LoanRateRegisterSaveRequest req) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
|
||||
if (req == null) {
|
||||
errors.add(error("payload", "payload.required", "Request body is required"));
|
||||
raise(errors);
|
||||
}
|
||||
|
||||
// Header fields
|
||||
if (isBlank(req.getName())) {
|
||||
errors.add(error("name", "name.required", "Register name is required"));
|
||||
} else if (req.getName().length() > 200) {
|
||||
errors.add(error("name", "name.exceeds.max.length", "Register name must be at most 200 characters"));
|
||||
}
|
||||
if (req.getFallbackNoCibilSlab() != null && req.getFallbackNoCibilSlab() < 1) {
|
||||
errors.add(error("fallbackNoCibilSlab", "fallback.no.cibil.slab.invalid", "Fallback slab pointer must be >= 1"));
|
||||
}
|
||||
if (req.getFallbackBelowMin() != null && !req.getFallbackBelowMin().equals("use-min-slab")
|
||||
&& !req.getFallbackBelowMin().equals("block")) {
|
||||
errors.add(error("fallbackBelowMin", "fallback.below.min.invalid",
|
||||
"fallbackBelowMin must be 'use-min-slab' or 'block'"));
|
||||
}
|
||||
|
||||
// Chart cells
|
||||
final List<SlabRequest> slabs = req.getSlabs();
|
||||
if (slabs == null || slabs.isEmpty()) {
|
||||
errors.add(error("slabs", "slabs.required", "At least one rate chart cell is required"));
|
||||
} else {
|
||||
for (int i = 0; i < slabs.size(); i++) {
|
||||
validateSlab(slabs.get(i), i, errors);
|
||||
}
|
||||
}
|
||||
|
||||
// CIBIL bands
|
||||
final List<CibilSlabRequest> cibil = req.getCibilSlabs();
|
||||
if (cibil == null || cibil.isEmpty()) {
|
||||
errors.add(error("cibilSlabs", "cibilSlabs.required", "At least one CIBIL slab is required"));
|
||||
} else {
|
||||
validateCibilCoverage(cibil, errors);
|
||||
}
|
||||
|
||||
raise(errors);
|
||||
}
|
||||
|
||||
private void validateSlab(final SlabRequest s, final int idx, final List<ApiParameterError> errors) {
|
||||
final String prefix = "slabs[" + idx + "]";
|
||||
if (s.getProductSrNo() == null) {
|
||||
errors.add(error(prefix + ".productSrNo", "productSrNo.required", "productSrNo is required"));
|
||||
}
|
||||
if (isBlank(s.getProductName())) {
|
||||
errors.add(error(prefix + ".productName", "productName.required", "productName is required"));
|
||||
}
|
||||
if (s.getSlabOrder() == null) {
|
||||
errors.add(error(prefix + ".slabOrder", "slabOrder.required", "slabOrder is required"));
|
||||
}
|
||||
if (isBlank(s.getSlabLabel())) {
|
||||
errors.add(error(prefix + ".slabLabel", "slabLabel.required", "slabLabel is required"));
|
||||
}
|
||||
// Exactly one of: rateValue populated, rateFormula populated, isNa=true. Both-null-and-not-NA is invalid.
|
||||
final boolean hasValue = s.getRateValue() != null;
|
||||
final boolean hasFormula = s.getRateFormula() != null && !s.getRateFormula().isBlank();
|
||||
final boolean na = Boolean.TRUE.equals(s.getIsNa());
|
||||
final int populated = (hasValue ? 1 : 0) + (hasFormula ? 1 : 0) + (na ? 1 : 0);
|
||||
if (populated == 0) {
|
||||
errors.add(error(prefix, "cell.empty",
|
||||
"Cell must have a rateValue, a rateFormula, or isNa=true"));
|
||||
} else if (populated > 1) {
|
||||
errors.add(error(prefix, "cell.ambiguous",
|
||||
"Cell must have exactly one of rateValue, rateFormula, or isNa"));
|
||||
}
|
||||
if (hasFormula && !Boolean.TRUE.equals(s.getIsFormula())) {
|
||||
errors.add(error(prefix + ".isFormula", "isFormula.mismatch",
|
||||
"isFormula must be true when rateFormula is set"));
|
||||
}
|
||||
}
|
||||
|
||||
/** CIBIL bands must cover 0..900 contiguously and must not overlap. */
|
||||
private void validateCibilCoverage(final List<CibilSlabRequest> cibil, final List<ApiParameterError> errors) {
|
||||
final List<CibilSlabRequest> sorted = new ArrayList<>(cibil);
|
||||
sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom())));
|
||||
final Set<Integer> seenOrders = new TreeSet<>();
|
||||
int expectedNextFrom = 0;
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
final CibilSlabRequest c = sorted.get(i);
|
||||
final String prefix = "cibilSlabs[" + i + "]";
|
||||
if (c.getScoreFrom() == null || c.getScoreTo() == null) {
|
||||
errors.add(error(prefix, "cibil.range.required", "scoreFrom and scoreTo are required"));
|
||||
continue;
|
||||
}
|
||||
if (c.getScoreFrom() > c.getScoreTo()) {
|
||||
errors.add(error(prefix, "cibil.range.inverted", "scoreFrom must be <= scoreTo"));
|
||||
}
|
||||
if (c.getScoreFrom() != expectedNextFrom) {
|
||||
errors.add(error(prefix, "cibil.range.gap_or_overlap",
|
||||
"CIBIL bands must be contiguous starting from 0 (expected " + expectedNextFrom
|
||||
+ ", got " + c.getScoreFrom() + ")"));
|
||||
}
|
||||
if (isBlank(c.getTier())) {
|
||||
errors.add(error(prefix + ".tier", "tier.required", "tier is required"));
|
||||
}
|
||||
if (c.getAdjustmentPct() == null) {
|
||||
errors.add(error(prefix + ".adjustmentPct", "adjustmentPct.required", "adjustmentPct is required"));
|
||||
}
|
||||
if (c.getDisplayOrder() == null || !seenOrders.add(c.getDisplayOrder())) {
|
||||
errors.add(error(prefix + ".displayOrder", "displayOrder.invalid",
|
||||
"displayOrder is required and must be unique"));
|
||||
}
|
||||
expectedNextFrom = c.getScoreTo() + 1;
|
||||
}
|
||||
if (!sorted.isEmpty() && expectedNextFrom <= 900) {
|
||||
errors.add(error("cibilSlabs", "cibil.coverage.incomplete",
|
||||
"CIBIL bands must cover up to 900 (last to was " + (expectedNextFrom - 1) + ")"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ApiParameterError error(final String field, final String code, final String msg) {
|
||||
return ApiParameterError.parameterError("validation." + RESOURCE_NAME + "." + code, msg, field);
|
||||
}
|
||||
|
||||
private static void raise(final List<ApiParameterError> errors) {
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg." + RESOURCE_NAME + ".failed",
|
||||
"Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(final String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static int nz(final Integer n) {
|
||||
return n != null ? n : 0;
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}).
|
||||
*
|
||||
* Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS},
|
||||
* so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product
|
||||
* create/update transaction, so a failure here rolls the whole product back.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanProductAdvanceExtService {
|
||||
|
||||
private final LoanProductAdvanceExtRepository repository;
|
||||
|
||||
/**
|
||||
* Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its
|
||||
* SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the
|
||||
* satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId,
|
||||
* graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product.
|
||||
*/
|
||||
public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount",
|
||||
"loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType",
|
||||
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
||||
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
||||
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
||||
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
||||
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
||||
"shareCapitalGl",
|
||||
// security & lien extras
|
||||
"eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure",
|
||||
"lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien",
|
||||
// limit & tenure extras
|
||||
"repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType",
|
||||
// interest extras
|
||||
"interestCalcMethod", "interestOnCreditBalance", "interestPostingDay",
|
||||
// NPA upgrade rules
|
||||
"npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers",
|
||||
// cheque book full config
|
||||
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
|
||||
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
|
||||
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
|
||||
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased",
|
||||
// Loans A/C (EMI) wizard fields
|
||||
"securityMandatory", "guarantorsRequired", "coApplicant", "thirdPartySecurity",
|
||||
"legalOpinionRequired", "propertyInsuranceRequired", "registerCharge", "releaseSecurityOnClosure",
|
||||
"allowMultipleSecurities", "allowThirdPartyDeposit", "minAge", "maxAgeAtMaturity",
|
||||
"minCibil", "minMonthlyIncome", "maxFoir", "maxLoansPerCustomer",
|
||||
"disbursementType", "disbursementMode", "undisbursedInterest", "preEmiInterest",
|
||||
"emiDueDay", "holidayRule", "moratoriumMonths", "moratoriumInterest",
|
||||
"variableInstallments", "repaymentMode", "linkedAccountType", "pdcCollection",
|
||||
"mandateRequired", "advanceEmi", "rateType", "registerParticular",
|
||||
"applyCibilAdjustment", "rateResolvedAt", "calcPeriod", "interestStartsFrom",
|
||||
"compounding", "benchmark", "resetFrequency", "resetChange",
|
||||
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
||||
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
||||
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
|
||||
|
||||
/**
|
||||
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
||||
* caller can fold them into the loan-product audit {@code changes} map.
|
||||
*/
|
||||
public Map<String, Object> saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) {
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
if (!hasAnyAdvanceParameter(command)) {
|
||||
return changes;
|
||||
}
|
||||
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
|
||||
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
|
||||
applyScalarFields(ext, command, changes);
|
||||
this.repository.save(ext);
|
||||
return changes;
|
||||
}
|
||||
|
||||
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
|
||||
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
|
||||
.orElseGet(LinkedHashMap::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every product's satellite in one query, keyed by loan product id. Used by the product list so a screen can
|
||||
* show and filter products by category and type without one extra call per product.
|
||||
*/
|
||||
public Map<Long, Map<String, Object>> retrieveAllAsMap() {
|
||||
final Map<Long, Map<String, Object>> byProduct = new LinkedHashMap<>();
|
||||
for (final LoanProductAdvanceExt ext : this.repository.findAll()) {
|
||||
byProduct.put(ext.getLoanProductId(), toMap(ext));
|
||||
}
|
||||
return byProduct;
|
||||
}
|
||||
|
||||
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
|
||||
for (final String param : ADVANCE_PARAMETERS) {
|
||||
if (command.parameterExists(param)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command,
|
||||
final Map<String, Object> changes) {
|
||||
setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes);
|
||||
setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes);
|
||||
setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes);
|
||||
setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes);
|
||||
setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes);
|
||||
setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes);
|
||||
setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes);
|
||||
setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes);
|
||||
setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes);
|
||||
setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes);
|
||||
setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes);
|
||||
setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes);
|
||||
setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes);
|
||||
setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes);
|
||||
setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes);
|
||||
setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes);
|
||||
setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes);
|
||||
setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired,
|
||||
changes);
|
||||
setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix,
|
||||
changes);
|
||||
setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement,
|
||||
ext::setPrintCertificateOnDisbursement, changes);
|
||||
setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes);
|
||||
setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes);
|
||||
setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes);
|
||||
setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency,
|
||||
ext::setInterestPostingFrequency, changes);
|
||||
setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes);
|
||||
setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes);
|
||||
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
|
||||
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
|
||||
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
|
||||
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
|
||||
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
|
||||
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
|
||||
setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes);
|
||||
setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes);
|
||||
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
|
||||
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
|
||||
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
|
||||
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
|
||||
changes);
|
||||
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
|
||||
setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths,
|
||||
ext::setStockStatementFreqMonths, changes);
|
||||
setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes);
|
||||
setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes);
|
||||
setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize,
|
||||
changes);
|
||||
setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes);
|
||||
setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes);
|
||||
setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes);
|
||||
// security & lien extras
|
||||
setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts,
|
||||
changes);
|
||||
setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity,
|
||||
ext::setLienTenureNotExceedMaturity, changes);
|
||||
setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure,
|
||||
ext::setLienAutoReleaseOnClosure, changes);
|
||||
setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits,
|
||||
ext::setLienAllowMultipleDeposits, changes);
|
||||
setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity,
|
||||
changes);
|
||||
setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes);
|
||||
setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien,
|
||||
ext::setLienAllowRenewalUnderLien, changes);
|
||||
// limit & tenure extras
|
||||
setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes);
|
||||
setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes);
|
||||
setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes);
|
||||
setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate,
|
||||
changes);
|
||||
setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType,
|
||||
changes);
|
||||
// interest extras
|
||||
setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes);
|
||||
setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance,
|
||||
changes);
|
||||
setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes);
|
||||
// NPA upgrade rules
|
||||
setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears,
|
||||
ext::setNpaUpgradeOnlyAllArrears, changes);
|
||||
setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers,
|
||||
ext::setNpaKeepStandardIfDepositCovers, changes);
|
||||
// cheque book full config
|
||||
setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes);
|
||||
setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding,
|
||||
ext::setChequeMaxBooksOutstanding, changes);
|
||||
setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves,
|
||||
changes);
|
||||
setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes);
|
||||
setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear,
|
||||
changes);
|
||||
setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes);
|
||||
setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription,
|
||||
changes);
|
||||
setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed,
|
||||
ext::setChequeStopPaymentAllowed, changes);
|
||||
setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister,
|
||||
ext::setChequeMaintainStockRegister, changes);
|
||||
setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip,
|
||||
ext::setChequeAllowWithdrawalSlip, changes);
|
||||
setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes);
|
||||
setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired,
|
||||
changes);
|
||||
setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes);
|
||||
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
|
||||
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
|
||||
changes);
|
||||
// Loans A/C (EMI) wizard fields
|
||||
setString(command, "securityMandatory", ext::getSecurityMandatory, ext::setSecurityMandatory, changes);
|
||||
setInteger(command, "guarantorsRequired", ext::getGuarantorsRequired, ext::setGuarantorsRequired, changes);
|
||||
setString(command, "coApplicant", ext::getCoApplicant, ext::setCoApplicant, changes);
|
||||
setString(command, "thirdPartySecurity", ext::getThirdPartySecurity, ext::setThirdPartySecurity, changes);
|
||||
setBoolean(command, "legalOpinionRequired", ext::getLegalOpinionRequired, ext::setLegalOpinionRequired, changes);
|
||||
setBoolean(command, "propertyInsuranceRequired", ext::getPropertyInsuranceRequired, ext::setPropertyInsuranceRequired, changes);
|
||||
setBoolean(command, "registerCharge", ext::getRegisterCharge, ext::setRegisterCharge, changes);
|
||||
setBoolean(command, "releaseSecurityOnClosure", ext::getReleaseSecurityOnClosure, ext::setReleaseSecurityOnClosure, changes);
|
||||
setBoolean(command, "allowMultipleSecurities", ext::getAllowMultipleSecurities, ext::setAllowMultipleSecurities, changes);
|
||||
setBoolean(command, "allowThirdPartyDeposit", ext::getAllowThirdPartyDeposit, ext::setAllowThirdPartyDeposit, changes);
|
||||
setInteger(command, "minAge", ext::getMinAge, ext::setMinAge, changes);
|
||||
setInteger(command, "maxAgeAtMaturity", ext::getMaxAgeAtMaturity, ext::setMaxAgeAtMaturity, changes);
|
||||
setInteger(command, "minCibil", ext::getMinCibil, ext::setMinCibil, changes);
|
||||
setDecimal(command, "minMonthlyIncome", ext::getMinMonthlyIncome, ext::setMinMonthlyIncome, changes);
|
||||
setDecimal(command, "maxFoir", ext::getMaxFoir, ext::setMaxFoir, changes);
|
||||
setInteger(command, "maxLoansPerCustomer", ext::getMaxLoansPerCustomer, ext::setMaxLoansPerCustomer, changes);
|
||||
setString(command, "disbursementType", ext::getDisbursementType, ext::setDisbursementType, changes);
|
||||
setString(command, "disbursementMode", ext::getDisbursementMode, ext::setDisbursementMode, changes);
|
||||
setString(command, "undisbursedInterest", ext::getUndisbursedInterest, ext::setUndisbursedInterest, changes);
|
||||
setBoolean(command, "preEmiInterest", ext::getPreEmiInterest, ext::setPreEmiInterest, changes);
|
||||
setString(command, "emiDueDay", ext::getEmiDueDay, ext::setEmiDueDay, changes);
|
||||
setString(command, "holidayRule", ext::getHolidayRule, ext::setHolidayRule, changes);
|
||||
setInteger(command, "moratoriumMonths", ext::getMoratoriumMonths, ext::setMoratoriumMonths, changes);
|
||||
setString(command, "moratoriumInterest", ext::getMoratoriumInterest, ext::setMoratoriumInterest, changes);
|
||||
setBoolean(command, "variableInstallments", ext::getVariableInstallments, ext::setVariableInstallments, changes);
|
||||
setString(command, "repaymentMode", ext::getRepaymentMode, ext::setRepaymentMode, changes);
|
||||
setString(command, "linkedAccountType", ext::getLinkedAccountType, ext::setLinkedAccountType, changes);
|
||||
setString(command, "pdcCollection", ext::getPdcCollection, ext::setPdcCollection, changes);
|
||||
setBoolean(command, "mandateRequired", ext::getMandateRequired, ext::setMandateRequired, changes);
|
||||
setString(command, "advanceEmi", ext::getAdvanceEmi, ext::setAdvanceEmi, changes);
|
||||
setString(command, "rateType", ext::getRateType, ext::setRateType, changes);
|
||||
setString(command, "registerParticular", ext::getRegisterParticular, ext::setRegisterParticular, changes);
|
||||
setBoolean(command, "applyCibilAdjustment", ext::getApplyCibilAdjustment, ext::setApplyCibilAdjustment, changes);
|
||||
setString(command, "rateResolvedAt", ext::getRateResolvedAt, ext::setRateResolvedAt, changes);
|
||||
setString(command, "calcPeriod", ext::getCalcPeriod, ext::setCalcPeriod, changes);
|
||||
setString(command, "interestStartsFrom", ext::getInterestStartsFrom, ext::setInterestStartsFrom, changes);
|
||||
setString(command, "compounding", ext::getCompounding, ext::setCompounding, changes);
|
||||
setString(command, "benchmark", ext::getBenchmark, ext::setBenchmark, changes);
|
||||
setString(command, "resetFrequency", ext::getResetFrequency, ext::setResetFrequency, changes);
|
||||
setString(command, "resetChange", ext::getResetChange, ext::setResetChange, changes);
|
||||
setBoolean(command, "partPrepaymentAllowed", ext::getPartPrepaymentAllowed, ext::setPartPrepaymentAllowed, changes);
|
||||
setDecimal(command, "minPartPayment", ext::getMinPartPayment, ext::setMinPartPayment, changes);
|
||||
setString(command, "allowedOn", ext::getAllowedOn, ext::setAllowedOn, changes);
|
||||
setString(command, "partPaymentsPerYear", ext::getPartPaymentsPerYear, ext::setPartPaymentsPerYear, changes);
|
||||
setString(command, "partPrepaymentMode", ext::getPartPrepaymentMode, ext::setPartPrepaymentMode, changes);
|
||||
setDecimal(command, "foreclosureFloatingIndividual", ext::getForeclosureFloatingIndividual, ext::setForeclosureFloatingIndividual, changes);
|
||||
setDecimal(command, "foreclosureFixedIndividual", ext::getForeclosureFixedIndividual, ext::setForeclosureFixedIndividual, changes);
|
||||
setDecimal(command, "foreclosureNonIndividual", ext::getForeclosureNonIndividual, ext::setForeclosureNonIndividual, changes);
|
||||
setDecimal(command, "foreclosureTakeover", ext::getForeclosureTakeover, ext::setForeclosureTakeover, changes);
|
||||
setInteger(command, "foreclosureQuoteValidDays", ext::getForeclosureQuoteValidDays, ext::setForeclosureQuoteValidDays, changes);
|
||||
setBoolean(command, "autoCloseOnZeroBalance", ext::getAutoCloseOnZeroBalance, ext::setAutoCloseOnZeroBalance, changes);
|
||||
setString(command, "nocLetter", ext::getNocLetter, ext::setNocLetter, changes);
|
||||
setString(command, "restructuring", ext::getRestructuring, ext::setRestructuring, changes);
|
||||
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
|
||||
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
|
||||
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
|
||||
}
|
||||
|
||||
// ---- typed setters: only write params that are present, and only report real changes ----------
|
||||
|
||||
private interface Getter<T> {
|
||||
|
||||
T get();
|
||||
}
|
||||
|
||||
private interface Ser<T> {
|
||||
|
||||
void set(T value);
|
||||
}
|
||||
|
||||
private <T> void putIfChanged(final String param, final T oldValue, final T newValue, final Ser<T> setter,
|
||||
final Map<String, Object> changes) {
|
||||
if (differs(oldValue, newValue)) {
|
||||
setter.set(newValue);
|
||||
changes.put(param, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void setString(final JsonCommand command, final String param, final Getter<String> getter,
|
||||
final Ser<String> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setLong(final JsonCommand command, final String param, final Getter<Long> getter,
|
||||
final Ser<Long> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setInteger(final JsonCommand command, final String param, final Getter<Integer> getter,
|
||||
final Ser<Integer> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private void setDecimal(final JsonCommand command, final String param, final Getter<BigDecimal> getter,
|
||||
final Ser<BigDecimal> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans;
|
||||
* accept both shapes so a "NO" is not silently read as null.
|
||||
*/
|
||||
private void setBoolean(final JsonCommand command, final String param, final Getter<Boolean> getter,
|
||||
final Ser<Boolean> setter, final Map<String, Object> changes) {
|
||||
if (command.parameterExists(param)) {
|
||||
putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes);
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean asBoolean(final JsonElement element) {
|
||||
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (element.getAsJsonPrimitive().isBoolean()) {
|
||||
return element.getAsBoolean();
|
||||
}
|
||||
final String raw = element.getAsString();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw);
|
||||
}
|
||||
|
||||
/** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */
|
||||
private static boolean differs(final Object oldValue, final Object newValue) {
|
||||
if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) {
|
||||
return a.compareTo(b) != 0;
|
||||
}
|
||||
return !Objects.equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private static Map<String, Object> toMap(final LoanProductAdvanceExt ext) {
|
||||
final Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("productTypeGroup", ext.getProductTypeGroup());
|
||||
map.put("productTypeSubGroup", ext.getProductTypeSubGroup());
|
||||
map.put("glAccount", ext.getGlAccountId());
|
||||
map.put("loanProductCategory", ext.getLoanProductCategory());
|
||||
map.put("prioritySector", ext.getPrioritySector());
|
||||
map.put("cibilAccountType", ext.getCibilAccountType());
|
||||
map.put("kfsEnabled", ext.getKfsEnabled());
|
||||
map.put("aprEnabled", ext.getAprEnabled());
|
||||
map.put("facilityType", ext.getFacilityType());
|
||||
map.put("securityType", ext.getSecurityType());
|
||||
map.put("chargeType", ext.getChargeType());
|
||||
map.put("eligibleAmountBasis", ext.getEligibleAmountBasis());
|
||||
map.put("margin", ext.getMarginPct());
|
||||
map.put("ltv", ext.getLtvPct());
|
||||
map.put("dpMethod", ext.getDpMethod());
|
||||
map.put("lienRequired", ext.getLienRequired());
|
||||
map.put("lienMarkingStage", ext.getLienMarkingStage());
|
||||
map.put("lienCertificateRequired", ext.getLienCertificateRequired());
|
||||
map.put("lienCertificatePrefix", ext.getLienCertificatePrefix());
|
||||
map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement());
|
||||
map.put("interestBasis", ext.getInterestBasis());
|
||||
map.put("spread", ext.getSpreadPct());
|
||||
map.put("interestChargedOn", ext.getInterestChargedOn());
|
||||
map.put("interestPostingFrequency", ext.getInterestPostingFrequency());
|
||||
map.put("penalApplicable", ext.getPenalApplicable());
|
||||
map.put("penalRate", ext.getPenalRatePct());
|
||||
map.put("penalAppliedOn", ext.getPenalAppliedOn());
|
||||
map.put("penalGraceDays", ext.getPenalGraceDays());
|
||||
map.put("npaTrigger", ext.getNpaTrigger());
|
||||
map.put("interestAfterNpa", ext.getInterestAfterNpa());
|
||||
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
|
||||
map.put("renewalAllowed", ext.getRenewalAllowed());
|
||||
map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths());
|
||||
map.put("renewalGraceDays", ext.getRenewalGraceDays());
|
||||
map.put("preclosureAllowed", ext.getPreclosureAllowed());
|
||||
map.put("lockInMonths", ext.getLockInMonths());
|
||||
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
|
||||
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
|
||||
map.put("insuranceRequired", ext.getInsuranceRequired());
|
||||
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
|
||||
map.put("chequeBookEnabled", ext.getChequeBookEnabled());
|
||||
map.put("chequeType", ext.getChequeType());
|
||||
map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize());
|
||||
map.put("ppsThreshold", ext.getPpsThreshold());
|
||||
map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove());
|
||||
map.put("shareCapitalGl", ext.getShareCapitalGlId());
|
||||
map.put("eligibleDepositProducts", ext.getEligibleDepositProducts());
|
||||
map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity());
|
||||
map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure());
|
||||
map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits());
|
||||
map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity());
|
||||
map.put("lienAllowThirdParty", ext.getLienAllowThirdParty());
|
||||
map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien());
|
||||
map.put("repaymentType", ext.getRepaymentType());
|
||||
map.put("appropriationOrder", ext.getAppropriationOrder());
|
||||
map.put("debitBalanceAllow", ext.getDebitBalanceAllow());
|
||||
map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate());
|
||||
map.put("branchLevelAccountType", ext.getBranchLevelAccountType());
|
||||
map.put("interestCalcMethod", ext.getInterestCalcMethod());
|
||||
map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance());
|
||||
map.put("interestPostingDay", ext.getInterestPostingDay());
|
||||
map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears());
|
||||
map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers());
|
||||
map.put("chequeBookSizes", ext.getChequeBookSizes());
|
||||
map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding());
|
||||
map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves());
|
||||
map.put("chequeFirstBookFree", ext.getChequeFirstBookFree());
|
||||
map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear());
|
||||
map.put("chequeNamePrinted", ext.getChequeNamePrinted());
|
||||
map.put("chequePrintDescription", ext.getChequePrintDescription());
|
||||
map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed());
|
||||
map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister());
|
||||
map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip());
|
||||
map.put("chequeBlockNpa", ext.getChequeBlockNpa());
|
||||
map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired());
|
||||
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
|
||||
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
|
||||
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
|
||||
// Loans A/C (EMI) wizard fields
|
||||
map.put("securityMandatory", ext.getSecurityMandatory());
|
||||
map.put("guarantorsRequired", ext.getGuarantorsRequired());
|
||||
map.put("coApplicant", ext.getCoApplicant());
|
||||
map.put("thirdPartySecurity", ext.getThirdPartySecurity());
|
||||
map.put("legalOpinionRequired", ext.getLegalOpinionRequired());
|
||||
map.put("propertyInsuranceRequired", ext.getPropertyInsuranceRequired());
|
||||
map.put("registerCharge", ext.getRegisterCharge());
|
||||
map.put("releaseSecurityOnClosure", ext.getReleaseSecurityOnClosure());
|
||||
map.put("allowMultipleSecurities", ext.getAllowMultipleSecurities());
|
||||
map.put("allowThirdPartyDeposit", ext.getAllowThirdPartyDeposit());
|
||||
map.put("minAge", ext.getMinAge());
|
||||
map.put("maxAgeAtMaturity", ext.getMaxAgeAtMaturity());
|
||||
map.put("minCibil", ext.getMinCibil());
|
||||
map.put("minMonthlyIncome", ext.getMinMonthlyIncome());
|
||||
map.put("maxFoir", ext.getMaxFoir());
|
||||
map.put("maxLoansPerCustomer", ext.getMaxLoansPerCustomer());
|
||||
map.put("disbursementType", ext.getDisbursementType());
|
||||
map.put("disbursementMode", ext.getDisbursementMode());
|
||||
map.put("undisbursedInterest", ext.getUndisbursedInterest());
|
||||
map.put("preEmiInterest", ext.getPreEmiInterest());
|
||||
map.put("emiDueDay", ext.getEmiDueDay());
|
||||
map.put("holidayRule", ext.getHolidayRule());
|
||||
map.put("moratoriumMonths", ext.getMoratoriumMonths());
|
||||
map.put("moratoriumInterest", ext.getMoratoriumInterest());
|
||||
map.put("variableInstallments", ext.getVariableInstallments());
|
||||
map.put("repaymentMode", ext.getRepaymentMode());
|
||||
map.put("linkedAccountType", ext.getLinkedAccountType());
|
||||
map.put("pdcCollection", ext.getPdcCollection());
|
||||
map.put("mandateRequired", ext.getMandateRequired());
|
||||
map.put("advanceEmi", ext.getAdvanceEmi());
|
||||
map.put("rateType", ext.getRateType());
|
||||
map.put("registerParticular", ext.getRegisterParticular());
|
||||
map.put("applyCibilAdjustment", ext.getApplyCibilAdjustment());
|
||||
map.put("rateResolvedAt", ext.getRateResolvedAt());
|
||||
map.put("calcPeriod", ext.getCalcPeriod());
|
||||
map.put("interestStartsFrom", ext.getInterestStartsFrom());
|
||||
map.put("compounding", ext.getCompounding());
|
||||
map.put("benchmark", ext.getBenchmark());
|
||||
map.put("resetFrequency", ext.getResetFrequency());
|
||||
map.put("resetChange", ext.getResetChange());
|
||||
map.put("partPrepaymentAllowed", ext.getPartPrepaymentAllowed());
|
||||
map.put("minPartPayment", ext.getMinPartPayment());
|
||||
map.put("allowedOn", ext.getAllowedOn());
|
||||
map.put("partPaymentsPerYear", ext.getPartPaymentsPerYear());
|
||||
map.put("partPrepaymentMode", ext.getPartPrepaymentMode());
|
||||
map.put("foreclosureFloatingIndividual", ext.getForeclosureFloatingIndividual());
|
||||
map.put("foreclosureFixedIndividual", ext.getForeclosureFixedIndividual());
|
||||
map.put("foreclosureNonIndividual", ext.getForeclosureNonIndividual());
|
||||
map.put("foreclosureTakeover", ext.getForeclosureTakeover());
|
||||
map.put("foreclosureQuoteValidDays", ext.getForeclosureQuoteValidDays());
|
||||
map.put("autoCloseOnZeroBalance", ext.getAutoCloseOnZeroBalance());
|
||||
map.put("nocLetter", ext.getNocLetter());
|
||||
map.put("restructuring", ext.getRestructuring());
|
||||
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
|
||||
map.put("topUpAllowed", ext.getTopUpAllowed());
|
||||
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+11
@@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
|
||||
private final LoanProductAssembler loanProductAssembler;
|
||||
private final LoanProductUpdateUtil loanProductUpdateUtil;
|
||||
// "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for.
|
||||
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
|
||||
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
|
||||
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
|
||||
|
||||
@@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
|
||||
this.loanProductRepository.saveAndFlush(loanProduct);
|
||||
|
||||
// "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries
|
||||
// none of these parameters and writes no row. Same transaction: a failure rolls the product back.
|
||||
this.loanProductAdvanceExtService.saveOrUpdate(loanProduct, command);
|
||||
|
||||
// save accounting mappings
|
||||
this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command);
|
||||
// check if the office specific products are enabled. If yes, then
|
||||
@@ -303,6 +309,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
|
||||
this.loanProductRepository.saveAndFlush(product);
|
||||
}
|
||||
|
||||
// "Advances A/C" satellite fields. Outside the changes check above (those track only the standard
|
||||
// product columns) so an edit that touched only an advance field is still saved, and its per-field
|
||||
// diff is folded into the same audit changes map.
|
||||
changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command));
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(loanProductId) //
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
|
||||
|
||||
public interface LoanRateRegisterReadPlatformService {
|
||||
|
||||
/** All versions, newest-effective first — the Version History accordion feeds off this. */
|
||||
List<LoanRateRegisterData> retrieveAll();
|
||||
|
||||
/** The current active version, assembled with its chart cells + CIBIL bands. */
|
||||
Optional<LoanRateRegisterData> retrieveActive();
|
||||
|
||||
/** A specific version by id. */
|
||||
Optional<LoanRateRegisterData> retrieveById(Long id);
|
||||
|
||||
/** Master list of particulars (loan types) that make up the chart rows, in chart order. */
|
||||
List<LoanRateRegisterProductData> retrieveProducts();
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
|
||||
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Assembles {@link LoanRateRegisterData} responses by joining the three physical tables. Kept
|
||||
* read-only — all writes go through {@link LoanRateRegisterWritePlatformService}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegisterReadPlatformService {
|
||||
|
||||
private final LoanRateRegisterEffectiveRepository effectiveRepository;
|
||||
private final LoanRateRegisterSlabHistoryRepository slabRepository;
|
||||
private final LoanRateRegisterCibilSlabRepository cibilRepository;
|
||||
private final LoanRateRegisterProductRepository productRepository;
|
||||
|
||||
@Override
|
||||
public List<LoanRateRegisterProductData> retrieveProducts() {
|
||||
final List<LoanRateRegisterProduct> products = this.productRepository.findByIsActiveTrueOrderBySrNoAsc();
|
||||
final List<LoanRateRegisterProductData> out = new ArrayList<>(products.size());
|
||||
for (final LoanRateRegisterProduct p : products) {
|
||||
out.add(LoanRateRegisterProductData.builder()
|
||||
.id(p.getId())
|
||||
.srNo(p.getSrNo())
|
||||
.name(p.getName())
|
||||
.glAccountId(p.getGlAccountId())
|
||||
.isActive(p.getIsActive())
|
||||
.build());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoanRateRegisterData> retrieveAll() {
|
||||
final List<LoanRateRegisterEffective> versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc();
|
||||
final List<LoanRateRegisterData> out = new ArrayList<>(versions.size());
|
||||
for (final LoanRateRegisterEffective v : versions) {
|
||||
out.add(assemble(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LoanRateRegisterData> retrieveActive() {
|
||||
return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LoanRateRegisterData> retrieveById(final Long id) {
|
||||
return this.effectiveRepository.findById(id).map(this::assemble);
|
||||
}
|
||||
|
||||
/** Build the full response for one effective version — header + its chart cells + its CIBIL bands. */
|
||||
private LoanRateRegisterData assemble(final LoanRateRegisterEffective v) {
|
||||
final List<LoanRateRegisterSlabHistory> cells = this.slabRepository
|
||||
.findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId());
|
||||
final List<LoanRateRegisterCibilSlab> bands = this.cibilRepository
|
||||
.findByEffectiveIdOrderByDisplayOrderAsc(v.getId());
|
||||
|
||||
final List<LoanRateRegisterSlabData> slabData = new ArrayList<>(cells.size());
|
||||
for (final LoanRateRegisterSlabHistory c : cells) {
|
||||
slabData.add(LoanRateRegisterSlabData.builder()
|
||||
.id(c.getId())
|
||||
.productId(c.getProductId())
|
||||
.productSrNo(c.getProductSrNo())
|
||||
.productName(c.getProductName())
|
||||
.slabOrder(c.getSlabOrder())
|
||||
.slabLabel(c.getSlabLabel())
|
||||
.amountUpto(c.getAmountUpto())
|
||||
.rateValue(c.getRateValue())
|
||||
.rateFormula(c.getRateFormula())
|
||||
.isFormula(c.getIsFormula())
|
||||
.isNa(c.getIsNa())
|
||||
.build());
|
||||
}
|
||||
|
||||
final List<LoanRateRegisterCibilSlabData> cibilData = new ArrayList<>(bands.size());
|
||||
for (final LoanRateRegisterCibilSlab b : bands) {
|
||||
cibilData.add(LoanRateRegisterCibilSlabData.builder()
|
||||
.id(b.getId())
|
||||
.scoreFrom(b.getScoreFrom())
|
||||
.scoreTo(b.getScoreTo())
|
||||
.tier(b.getTier())
|
||||
.slabType(b.getSlabType())
|
||||
.adjustmentPct(b.getAdjustmentPct())
|
||||
.displayOrder(b.getDisplayOrder())
|
||||
.build());
|
||||
}
|
||||
|
||||
return LoanRateRegisterData.builder()
|
||||
.id(v.getId())
|
||||
.name(v.getName())
|
||||
.effectiveFromDate(v.getEffectiveFromDate())
|
||||
.effectiveToDate(v.getEffectiveToDate())
|
||||
.currencyCode(v.getCurrencyCode())
|
||||
.isActive(v.getIsActive())
|
||||
.fallbackNoCibilSlab(v.getFallbackNoCibilSlab())
|
||||
.fallbackBelowMin(v.getFallbackBelowMin())
|
||||
.note(v.getNote())
|
||||
.createdById(v.getCreatedById())
|
||||
.createdDate(v.getCreatedDate())
|
||||
.slabs(slabData)
|
||||
.cibilSlabs(cibilData)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
|
||||
public interface LoanRateRegisterWritePlatformService {
|
||||
|
||||
/** Save a new version of the loan interest rate register (maker-checker gated upstream). */
|
||||
CommandProcessingResult saveRateRegister(JsonCommand command);
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.ProductRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
|
||||
import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Persists a new effective version of the Loan Interest Rate Register. Called from the command
|
||||
* handler, which Fineract routes to only AFTER a checker has approved the maker's submission. The
|
||||
* whole save runs inside one transaction: previous active deactivated → new effective inserted →
|
||||
* all chart cells + CIBIL bands inserted. Any failure rolls everything back.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegisterWritePlatformService {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanRateRegisterDataValidator validator;
|
||||
private final LoanRateRegisterEffectiveRepository effectiveRepository;
|
||||
private final LoanRateRegisterSlabHistoryRepository slabRepository;
|
||||
private final LoanRateRegisterCibilSlabRepository cibilRepository;
|
||||
private final LoanRateRegisterProductRepository productRepository;
|
||||
private final GLAccountRepositoryWrapper glAccountRepositoryWrapper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult saveRateRegister(final JsonCommand command) {
|
||||
final LoanRateRegisterSaveRequest req = gson.fromJson(command.json(), LoanRateRegisterSaveRequest.class);
|
||||
this.validator.validate(req);
|
||||
|
||||
final Long createdById = this.context.authenticatedUser().getId();
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today);
|
||||
|
||||
// 0) Upsert the particulars master first, so a missing GL on a new product fails the whole
|
||||
// save before any version row is touched. Returns srNo -> master row for the cells below.
|
||||
final Map<Integer, LoanRateRegisterProduct> productsBySrNo = upsertProducts(req);
|
||||
|
||||
// 1) Deactivate the currently-active version (if any).
|
||||
final List<LoanRateRegisterEffective> active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc();
|
||||
for (final LoanRateRegisterEffective prior : active) {
|
||||
prior.deactivate(today);
|
||||
}
|
||||
if (!active.isEmpty()) {
|
||||
this.effectiveRepository.saveAll(active);
|
||||
}
|
||||
|
||||
// 2) Insert the new effective row. saveAndFlush ensures Hibernate executes the INSERT
|
||||
// immediately so the auto-generated id is populated before we use it as the FK on the
|
||||
// child slab/CIBIL rows (same pattern as FD side's FdBaseInterestRateReadPlatformServiceImpl).
|
||||
final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom,
|
||||
req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById);
|
||||
final LoanRateRegisterEffective saved = this.effectiveRepository.saveAndFlush(fresh);
|
||||
final Long versionId = saved.getId();
|
||||
|
||||
// 3) Insert all chart cells for this version.
|
||||
final List<LoanRateRegisterSlabHistory> cells = new ArrayList<>(req.getSlabs().size());
|
||||
for (final SlabRequest s : req.getSlabs()) {
|
||||
final Long productId = productsBySrNo.get(s.getProductSrNo()).getId();
|
||||
cells.add(LoanRateRegisterSlabHistory.create(versionId, productId, s.getProductSrNo(), s.getProductName(),
|
||||
s.getSlabOrder(), s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
|
||||
Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa())));
|
||||
}
|
||||
this.slabRepository.saveAll(cells);
|
||||
|
||||
// 4) Insert all CIBIL bands for this version.
|
||||
final List<LoanRateRegisterCibilSlab> bands = new ArrayList<>(req.getCibilSlabs().size());
|
||||
for (final CibilSlabRequest c : req.getCibilSlabs()) {
|
||||
bands.add(LoanRateRegisterCibilSlab.create(versionId, c.getScoreFrom(), c.getScoreTo(), c.getTier(),
|
||||
c.getSlabType(), c.getAdjustmentPct(), c.getDisplayOrder()));
|
||||
}
|
||||
this.cibilRepository.saveAll(bands);
|
||||
|
||||
return new CommandProcessingResultBuilder()
|
||||
.withCommandId(command.commandId())
|
||||
.withEntityId(versionId)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every particular used by the submitted chart exists in the master. A particular not
|
||||
* yet in the master is created and must carry a valid GL account; for an existing one a changed
|
||||
* GL or name is applied. A particular that still has no GL after this (seeded rows the bank has
|
||||
* not mapped yet) rejects the whole save.
|
||||
*/
|
||||
private Map<Integer, LoanRateRegisterProduct> upsertProducts(final LoanRateRegisterSaveRequest req) {
|
||||
final Map<Integer, LoanRateRegisterProduct> bySrNo = new HashMap<>();
|
||||
for (final LoanRateRegisterProduct p : this.productRepository.findAll()) {
|
||||
bySrNo.put(p.getSrNo(), p);
|
||||
}
|
||||
final Map<Integer, ProductRequest> requested = new HashMap<>();
|
||||
if (req.getProducts() != null) {
|
||||
for (final ProductRequest pr : req.getProducts()) {
|
||||
requested.put(pr.getSrNo(), pr);
|
||||
}
|
||||
}
|
||||
// Every srNo the chart uses, with a label taken from the first cell that mentions it.
|
||||
final Set<Integer> used = new LinkedHashSet<>();
|
||||
final Map<Integer, String> cellLabel = new HashMap<>();
|
||||
for (final SlabRequest s : req.getSlabs()) {
|
||||
used.add(s.getProductSrNo());
|
||||
cellLabel.putIfAbsent(s.getProductSrNo(), s.getProductName());
|
||||
}
|
||||
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final List<LoanRateRegisterProduct> toSave = new ArrayList<>();
|
||||
for (final Integer srNo : used) {
|
||||
final ProductRequest pr = requested.get(srNo);
|
||||
LoanRateRegisterProduct existing = bySrNo.get(srNo);
|
||||
final String name = pr != null && pr.getName() != null && !pr.getName().isBlank() ? pr.getName() : cellLabel.get(srNo);
|
||||
if (existing == null) {
|
||||
if (pr == null || pr.getGlAccountId() == null) {
|
||||
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
|
||||
"New product '" + name + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
|
||||
continue;
|
||||
}
|
||||
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
|
||||
existing = new LoanRateRegisterProduct();
|
||||
existing.setSrNo(srNo);
|
||||
existing.setName(name);
|
||||
existing.setGlAccountId(pr.getGlAccountId());
|
||||
existing.setIsActive(true);
|
||||
existing.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
toSave.add(existing);
|
||||
} else if (pr != null) {
|
||||
boolean changed = false;
|
||||
if (pr.getGlAccountId() != null && !pr.getGlAccountId().equals(existing.getGlAccountId())) {
|
||||
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
|
||||
existing.setGlAccountId(pr.getGlAccountId());
|
||||
changed = true;
|
||||
}
|
||||
if (name != null && !name.equals(existing.getName())) {
|
||||
existing.setName(name);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
toSave.add(existing);
|
||||
}
|
||||
}
|
||||
// Every particular in a submitted chart must end up with a GL, including the seeded ones.
|
||||
if (existing.getGlAccountId() == null) {
|
||||
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
|
||||
"Product '" + existing.getName() + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
|
||||
continue;
|
||||
}
|
||||
bySrNo.put(srNo, existing);
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.loanRateRegister.failed", "Validation errors exist.", errors);
|
||||
}
|
||||
for (final LoanRateRegisterProduct p : this.productRepository.saveAllAndFlush(toSave)) {
|
||||
bySrNo.put(p.getSrNo(), p);
|
||||
}
|
||||
return bySrNo;
|
||||
}
|
||||
|
||||
private static LocalDate parseDateOrToday(final String iso, final LocalDate today) {
|
||||
if (iso == null || iso.isBlank()) {
|
||||
return today;
|
||||
}
|
||||
return LocalDate.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl;
|
||||
import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.rate.service.RateReadService;
|
||||
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
|
||||
DelinquencyBucketRepository delinquencyBucketRepository,
|
||||
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
|
||||
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
|
||||
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
|
||||
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
|
||||
LoanProductAdvanceExtService loanProductAdvanceExtService) {
|
||||
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
|
||||
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
|
||||
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
|
||||
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
|
||||
loanProductAssembler, loanProductUpdateUtil);
|
||||
loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService);
|
||||
}
|
||||
}
|
||||
|
||||
+53
-6
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
|
||||
"The generated return must be supplied with the save.");
|
||||
}
|
||||
|
||||
final LocalDate periodStart = localDate(report, "fortnightStart");
|
||||
final LocalDate periodEnd = localDate(report, "fortnightEnd");
|
||||
final BigDecimal ndtl = decimal(report, "ndtlAmount");
|
||||
final BigDecimal required = decimal(report, "requiredAmount");
|
||||
final BigDecimal maintained = decimal(report, "maintainedAmount");
|
||||
final BigDecimal rate = decimal(report, "ratePercentage");
|
||||
// The statutory form states its figures per column inside Part B rather than once at the
|
||||
// top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
|
||||
// read out of it. Without this a filed statutory return listed as a row of dashes, and the
|
||||
// list could not be scanned for the one return somebody was looking for.
|
||||
final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
|
||||
&& "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
|
||||
|
||||
// The statutory form's period runs between its two fortnight closes, which is what the
|
||||
// saved list prints as the row's period.
|
||||
final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
|
||||
final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
|
||||
// The current fortnight's figures are the second column - the close the form is made up to.
|
||||
final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
|
||||
final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
|
||||
final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
|
||||
: decimal(report, "maintainedAmount");
|
||||
final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage");
|
||||
BigDecimal average = null;
|
||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
|
||||
final String value = object.get(field).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a statutory part's per-column figures, taken at the column the form is made up to.
|
||||
*
|
||||
* The form carries three columns and the saved list has one cell per figure, so it takes the
|
||||
* second - the close the return reports. Index 1 rather than "the last": the third column is
|
||||
* the base the ratios are charged on, not a later position, and listing it would show a
|
||||
* requirement against liabilities from a fortnight nobody filed.
|
||||
*/
|
||||
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject partObject = report.getAsJsonObject(part);
|
||||
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var values = partObject.getAsJsonArray(field);
|
||||
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(1).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/** One of the statutory form's column dates, for the period a saved row reports. */
|
||||
private static LocalDate columnDate(final JsonObject report, final int index) {
|
||||
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var columns = report.getAsJsonArray("columns");
|
||||
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = columns.get(index).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-8
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
|
||||
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");
|
||||
ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
|
||||
|
||||
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
|
||||
if (role.startsWith("OSS1_")) {
|
||||
@@ -209,12 +210,14 @@ public class RbiReserveConfigApiResource {
|
||||
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
|
||||
} else {
|
||||
ROLE_GROUPS.put(role, "RESERVE");
|
||||
// Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so
|
||||
// the part is named rather than a separate return that does not exist.
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
ROLE_USAGE.put(role, "Form 1 — base for both ratios");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,9 +230,15 @@ public class RbiReserveConfigApiResource {
|
||||
*/
|
||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
||||
|
||||
/** The four treatments a head can be given, each naming a branch the returns already have. */
|
||||
/**
|
||||
* The treatments a head can be given, each naming a branch the returns already have.
|
||||
*
|
||||
* ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
|
||||
* at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
|
||||
* subtracted from the liabilities of a return that has no such base.
|
||||
*/
|
||||
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
||||
"MEMO");
|
||||
"ASSET", "MEMO");
|
||||
|
||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
static {
|
||||
@@ -243,6 +252,27 @@ public class RbiReserveConfigApiResource {
|
||||
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
||||
}
|
||||
|
||||
/**
|
||||
* How a head is consumed, in the terms of the return it belongs to.
|
||||
*
|
||||
* LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
|
||||
* head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
|
||||
* base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
|
||||
* wording would tell a Form IX filer their head feeds a calculation that does not exist on
|
||||
* their return.
|
||||
*/
|
||||
private static String usedBy(final String group, final String usage) {
|
||||
if ("FORM9".equals(group)) {
|
||||
return switch (usage == null ? "" : usage) {
|
||||
case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
|
||||
case "ASSET" -> "Form IX — an asset; enters Total Assets";
|
||||
case "MEMO" -> "Form IX — stated on the form but entering no total";
|
||||
default -> usage;
|
||||
};
|
||||
}
|
||||
return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
|
||||
}
|
||||
|
||||
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
|
||||
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
|
||||
static {
|
||||
@@ -390,11 +420,14 @@ public class RbiReserveConfigApiResource {
|
||||
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("usedBy", usedBy(group, 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));
|
||||
// A liability head carries the demand/time split; nothing else does. Not on Form IX,
|
||||
// where the demand and time heads are separate lines of the form itself - asking for a
|
||||
// class on top of that would let a head declared under 5(a) Fixed Deposits be marked
|
||||
// DEMAND, which the form has no way to report.
|
||||
o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
|
||||
o.put("roleId", row.get("id"));
|
||||
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
|
||||
out.add(o);
|
||||
|
||||
+1691
-24
File diff suppressed because it is too large
Load Diff
+61
-13
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Slf4j
|
||||
public class RbiFormAAutoSaveService {
|
||||
|
||||
private static final String FORM_CODE = "FORM_1";
|
||||
// The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
|
||||
// Saving the working report's shape left rows in the list that opened as a blank statement:
|
||||
// the viewer built for the statutory form found none of the fields it reads.
|
||||
private static final String FORM_CODE = "FORM_1_STATUTORY";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final RbiReserveReturnApiResource returnApiResource;
|
||||
@@ -89,7 +92,13 @@ public class RbiFormAAutoSaveService {
|
||||
try {
|
||||
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
||||
// saved by hand cannot come out differently.
|
||||
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
|
||||
// No column dates passed: today IS a fortnight close - the check above returned
|
||||
// otherwise - so the form derives its own pair from it, which is the pair an unattended
|
||||
// run should file. A filer needing a different period enters both dates on the screen.
|
||||
// No base dates passed either: an unattended run files the base the rules fix, which is
|
||||
// what a saved return should carry. A filer needing a different base enters it on screen.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null),
|
||||
JsonObject.class);
|
||||
} catch (final RuntimeException e) {
|
||||
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
|
||||
@@ -98,18 +107,22 @@ public class RbiFormAAutoSaveService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
|
||||
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
|
||||
// The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
|
||||
// on it - a bank with no balances at the State Co-operative Bank files that line empty - so
|
||||
// the form is filed as it stands. What it cannot do without is a rate, because every
|
||||
// required amount below is a percentage of one, and saving a return whose requirement is
|
||||
// zero for want of configuration would put a compliant-looking statement on the record.
|
||||
if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
|
||||
log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
|
||||
today);
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate periodStart = date(report, "fortnightStart");
|
||||
final LocalDate periodEnd = date(report, "fortnightEnd");
|
||||
BigDecimal average = null;
|
||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||
}
|
||||
// The form's period runs between its two fortnight closes, and its headline figures sit per
|
||||
// column inside the parts rather than once at the top.
|
||||
final LocalDate periodStart = column(report, 0);
|
||||
final LocalDate periodEnd = column(report, 1);
|
||||
final BigDecimal average = null;
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_rbi_form_a_filing
|
||||
@@ -119,9 +132,9 @@ public class RbiFormAAutoSaveService {
|
||||
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", FORM_CODE, java.sql.Date.valueOf(today),
|
||||
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
||||
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
|
||||
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
|
||||
"Saved automatically at the close of the fortnight.");
|
||||
gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"),
|
||||
partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
|
||||
decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight.");
|
||||
|
||||
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
|
||||
}
|
||||
@@ -143,6 +156,41 @@ public class RbiFormAAutoSaveService {
|
||||
return object.get(field).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the statutory form's per-column figures, at the close the return is made up to.
|
||||
*
|
||||
* Index 1, the second column: the third is the base the ratios are charged on, not a later
|
||||
* position, and listing it would show a requirement against a fortnight nobody filed.
|
||||
*/
|
||||
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject partObject = report.getAsJsonObject(part);
|
||||
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var values = partObject.getAsJsonArray(field);
|
||||
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(1).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/** One of the form's column dates. */
|
||||
private static LocalDate column(final JsonObject report, final int index) {
|
||||
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var columns = report.getAsJsonArray("columns");
|
||||
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = columns.get(index).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static LocalDate date(final JsonObject object, final String field) {
|
||||
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||
return null;
|
||||
|
||||
+81
-70
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
||||
* Runs the day's savings interest: accrue for every account, and post interest only from approved
|
||||
* interest posting reports ({@link SavingsInterestPostingReportService}).
|
||||
*
|
||||
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
|
||||
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
|
||||
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsDailyInterestService accrualService;
|
||||
private final SavingsPooledInterestPostingService postingService;
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Accrue for the day, then post for any product whose posting date has arrived.
|
||||
* The nightly run: settles any approved interest report still unposted, then accrues the day.
|
||||
*
|
||||
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
|
||||
* posting then reads and updates those same rows in a transaction of its own
|
||||
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
|
||||
* another's payment). Holding an outer transaction across both would mean this thread still owns
|
||||
* the row locks the accrual took while the posting's separate transaction waits for them — each
|
||||
* waiting on the other, and the job hanging silently with nothing in the log. So each step
|
||||
* commits on its own before the next begins.
|
||||
* <p>
|
||||
* This run no longer decides to pay anybody. Interest is paid only from an approved posting
|
||||
* report, normally when the last branch closes the day. What is left here is the safety net for a
|
||||
* report that was approved but could not be posted then, and it runs BEFORE the accrual on
|
||||
* purpose: by the time this job runs the business date has already moved past the posting date,
|
||||
* and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
|
||||
* for the posting to settle.
|
||||
*
|
||||
* <p>
|
||||
* Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
|
||||
* accrual then commits on its own, so neither holds the other's row locks.
|
||||
*/
|
||||
public void runForDate(final LocalDate businessDate) {
|
||||
clearStaleRunningFlag();
|
||||
final List<String> failures = postApprovedReports(businessDate);
|
||||
accrualService.accrueForDate(businessDate);
|
||||
enrolNewProducts();
|
||||
|
||||
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
||||
SELECT s.savings_product_id, s.posting_period_enum
|
||||
FROM m_savings_interest_posting_schedule s
|
||||
JOIN m_savings_product p ON p.id = s.savings_product_id
|
||||
WHERE s.is_active = true AND p.deposit_type_enum = 100
|
||||
ORDER BY s.savings_product_id
|
||||
""");
|
||||
|
||||
// Products that could not be paid, collected so the run can fail loudly at the end.
|
||||
//
|
||||
// Carrying on past one product is right — a mapping missing on one product must not stop
|
||||
// every member of every other from being paid. Reporting the job as successful afterwards is
|
||||
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
|
||||
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
|
||||
// nothing reads. On a night when every product fails, that is a whole day of interest
|
||||
// silently unpaid.
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
|
||||
for (final Map<String, Object> schedule : schedules) {
|
||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||
|
||||
// Whether anything is due is decided inside postForProduct, not here.
|
||||
//
|
||||
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
|
||||
// deadlocked the run: accrueForDate above is @Transactional and has just written every
|
||||
// ledger row, so its transaction still holds them; the COUNT then read those rows on
|
||||
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
|
||||
// transaction of its own — sat waiting for a lock this thread was holding and would only
|
||||
// release after postForProduct returned. Each waited on the other and the job hung
|
||||
// silently, with nothing in the log and the flag left saying it was still running.
|
||||
//
|
||||
// postForProduct already selects what is due and returns zero when there is nothing, so
|
||||
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
|
||||
// than by the run date, which is what makes a missed posting date catch up on a later
|
||||
// run instead of being skipped for good.
|
||||
try {
|
||||
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||
if (paid.signum() == 0) {
|
||||
updateSchedule(productId, businessDate, null);
|
||||
continue;
|
||||
}
|
||||
updateSchedule(productId, businessDate, "COMPLETED");
|
||||
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
||||
} catch (final RuntimeException e) {
|
||||
// The product rolled back on its own; record why and let the others run.
|
||||
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
||||
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
|
||||
// the row — which is exactly what a product with no GL mapping does. The failure is
|
||||
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE savings_product_id = ? AND posting_date = ?
|
||||
""", truncate(e.getMessage()), productId, businessDate);
|
||||
updateSchedule(productId, businessDate, "FAILED");
|
||||
failures.add("product " + productId + ": " + e.getMessage());
|
||||
}
|
||||
// Keeps each product's next posting date on the timetable in step with the ledger, which
|
||||
// the posting loop used to do as a side effect of looking for work.
|
||||
for (final Long productId : jdbcTemplate.queryForList(
|
||||
"SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
|
||||
updateSchedule(productId, businessDate, null);
|
||||
}
|
||||
|
||||
// Fails the job so the scheduler records it as failed and an operator is told. Every product
|
||||
// that could be paid has already been paid and committed by this point — this reports what
|
||||
// happened, it does not undo it.
|
||||
// Fails the job so the scheduler records it as failed and an operator is told. Every report
|
||||
// that could be paid has already been paid and committed by this point.
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
||||
+ failures.size() + " product(s): " + String.join("; ", failures));
|
||||
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts every approved interest posting report dated on or before the date.
|
||||
*
|
||||
* <p>
|
||||
* Each report posts in a new transaction of its own. That is required, not a preference: this is
|
||||
* called from the day close's after-commit callback, where the closing transaction has finished
|
||||
* but is still bound to the thread, and joining it would write nothing that ever commits. It also
|
||||
* keeps one product's failure from rolling back another's payment. A report that fails stays
|
||||
* APPROVED with the reason recorded, and the nightly run tries it again.
|
||||
*
|
||||
* @return one message per report that could not be posted
|
||||
*/
|
||||
public List<String> postApprovedReports(final LocalDate upToDate) {
|
||||
return postReports(reportService.approvedReportIds(upToDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the given approved reports, each in a new transaction of its own (see
|
||||
* {@link #postApprovedReports}). Used by the day close for one branch's reports.
|
||||
*
|
||||
* @return one message per report that could not be posted
|
||||
*/
|
||||
public List<String> postReports(final List<Long> reportIds) {
|
||||
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
|
||||
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
for (final Long reportId : reportIds) {
|
||||
final Map<String, Object> report = jdbcTemplate.queryForMap(
|
||||
"SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
try {
|
||||
final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
|
||||
newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
|
||||
log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
reportService.recordPostingFailure(reportId, e.getMessage());
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE report_id = ?
|
||||
""", truncate(e.getMessage()), reportId);
|
||||
updateSchedule(productId, postingDate, "FAILED");
|
||||
});
|
||||
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases this job's own currently_running flag if an earlier run left it set.
|
||||
*
|
||||
|
||||
+27
-7
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
|
||||
carriedInterest = backfill.cumulativeInterest();
|
||||
} else {
|
||||
final Map<String, Object> prev = previous.get(0);
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||
// A cycle is finished either because it was paid out or because its end date has gone
|
||||
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
|
||||
// run was missed, or the account was accrued again after a break - and treating it as
|
||||
// unfinished would leave the row in a cycle that ended before the day it records.
|
||||
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
|
||||
carriedInterest = BigDecimal.ZERO;
|
||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||
cycleStart = prevCycleEnd.plusDays(1);
|
||||
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||
financialYearBeginningMonth);
|
||||
// The new cycle may already be days old: the business date moved on by more than a
|
||||
// day, or nights were missed. Those days earned too. Starting the cycle at zero and
|
||||
// counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
|
||||
// October. They are replayed from the account's transactions, as a first row is, and
|
||||
// the opening balance comes from the same replay so money moved in the gap counts.
|
||||
if (cycleStart.isBefore(businessDate)) {
|
||||
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||
minBalance, averageDailyBalanceOf(calcType));
|
||||
openingBalance = gap.closingBalance();
|
||||
carriedInterest = gap.cumulativeInterest();
|
||||
} else {
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
carriedInterest = BigDecimal.ZERO;
|
||||
}
|
||||
} else {
|
||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
|
||||
// So the gap is replayed from the account's own transactions, the same way a first
|
||||
// row is built, and the result stands in for the running total the ledger would be
|
||||
// holding had it run every night.
|
||||
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
|
||||
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||
minBalance, averageDailyBalanceOf(calcType));
|
||||
// The replay's balance, not the stale row's: deposits made during the gap were
|
||||
// otherwise left out of every balance from here on.
|
||||
openingBalance = gap.closingBalance();
|
||||
carriedInterest = gap.cumulativeInterest();
|
||||
} else {
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||
}
|
||||
}
|
||||
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
|
||||
|
||||
if (!cycleStart.isBefore(upTo)) {
|
||||
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
// No day of the cycle to replay — it opens today — but the money held going into it is
|
||||
// still there. Returning a zero balance made every re-accrual on a cycle's first day (a
|
||||
// deposit, or the noon report, on the 1st) open the account at nil: an account holding
|
||||
// 11,503.15 then earned only on what was deposited that day.
|
||||
return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
|
||||
@@ -560,7 +580,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -577,7 +597,7 @@ public class SavingsDailyInterestService {
|
||||
SELECT t.transaction_date AS d,
|
||||
SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END) AS net
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -593,7 +613,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* The interest posting report: what each savings account of one branch will be paid on its product's
|
||||
* posting date, put in front of that branch before any money moves.
|
||||
*
|
||||
* <p>
|
||||
* One report per branch and product. On a posting date the noon job generates one for every branch
|
||||
* holding accounts with interest due; a user of that branch may generate or regenerate it at any time
|
||||
* — the previous report is superseded, not overwritten — and approves it. The branch's approved
|
||||
* reports are posted when that branch closes its day, and until they are approved it cannot close.
|
||||
* The head office closes last, once every branch open that day has closed.
|
||||
*
|
||||
* <p>
|
||||
* Only the branch acts on its own reports. The single exception is a branch that did not open that
|
||||
* day: the head office may then generate and approve its report on its behalf, and posts it at the
|
||||
* head office's own close. Every entry is still raised in the branch the customers belong to.
|
||||
*
|
||||
* <p>
|
||||
* The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
|
||||
* two decimals, with the paise the rounding loses given to the largest balance so the report's total
|
||||
* is the branch's pooled interest exactly.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SavingsInterestPostingReportService {
|
||||
|
||||
public static final String GENERATED = "GENERATED";
|
||||
public static final String APPROVED = "APPROVED";
|
||||
public static final String POSTED = "POSTED";
|
||||
public static final String SUPERSEDED = "SUPERSEDED";
|
||||
|
||||
private static final int MONEY_SCALE = 2;
|
||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||
/** m_savings_account.status_enum for an active account. */
|
||||
private static final int ACTIVE = 300;
|
||||
|
||||
/** A product with interest due in one branch. */
|
||||
private record DueKey(Long productId, Long officeId) {}
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsDailyInterestService accrualService;
|
||||
private final SavingsPooledInterestPostingService postingService;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
|
||||
final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.accrualService = accrualService;
|
||||
this.postingService = postingService;
|
||||
// Always a transaction of its own. The noon job runs inside the batch step's transaction, and
|
||||
// generating in it would read the accrual through that uncommitted transaction and hold its
|
||||
// row locks for the whole run; a report committed on its own is also visible to the screen at once.
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
// ── generation ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The noon job: brings the ledger up to the business date, then generates a report for every
|
||||
* branch and product with interest due that does not already have one.
|
||||
*
|
||||
* <p>
|
||||
* Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
|
||||
* say — does not stop the others. The failures are raised together at the end so the job is
|
||||
* recorded as failed.
|
||||
*
|
||||
* @return the ids of the reports generated
|
||||
*/
|
||||
public List<Long> generateDue(final LocalDate postingDate) {
|
||||
// So each account's figure includes the posting date itself, on the balance it holds now.
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
|
||||
|
||||
final List<Long> generated = new ArrayList<>();
|
||||
final List<String> failures = new ArrayList<>();
|
||||
for (final DueKey due : dueKeys(postingDate, null)) {
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
|
||||
due.officeId(), postingDate, e);
|
||||
failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
|
||||
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the report afresh for one branch and product, on the user's request.
|
||||
*
|
||||
* <p>
|
||||
* Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
|
||||
* date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
|
||||
* has moved. An approved one can, and has to be approved again.
|
||||
*/
|
||||
public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
if (postingDate.isAfter(businessDate)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
|
||||
"The report for " + postingDate + " cannot be generated before that date.");
|
||||
}
|
||||
authorize(user, officeId, businessDate);
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||
return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a generated report. A user of the report's branch approves it; the head office may
|
||||
* approve it only for a branch that did not open that day, and the report records that it did.
|
||||
*/
|
||||
public void approve(final Long reportId, final AppUser user) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate
|
||||
.queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||
"Interest posting report " + reportId + " does not exist.");
|
||||
}
|
||||
final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
|
||||
final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
|
||||
final int approved = jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report
|
||||
SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
|
||||
WHERE id = ? AND status = 'GENERATED'
|
||||
""", user.getId(), onBehalf, reportId);
|
||||
if (approved == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
|
||||
"Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
|
||||
+ "regenerated or posted already.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── day close ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What stops one branch from closing the day: a product with interest due in the branch and no
|
||||
* approved (or posted) report for the date, and any older report of the branch still awaiting
|
||||
* approval — one left from a day the branch did not open. A report that was never generated
|
||||
* blocks the close too.
|
||||
*/
|
||||
public List<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
|
||||
final Set<String> blocking = new LinkedHashSet<>();
|
||||
for (final DueKey due : dueKeys(businessDate, officeId)) {
|
||||
final Long settled = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
|
||||
""", Long.class, due.productId(), officeId, businessDate);
|
||||
if (settled == null || settled == 0) {
|
||||
blocking.add(productName(due.productId()) + " (" + businessDate + ")");
|
||||
}
|
||||
}
|
||||
for (final Map<String, Object> pending : jdbcTemplate.queryForList("""
|
||||
SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
|
||||
ORDER BY r.posting_date
|
||||
""", officeId, businessDate)) {
|
||||
blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
|
||||
}
|
||||
return new ArrayList<>(blocking);
|
||||
}
|
||||
|
||||
/** The branch's approved reports, posted when it closes the day. */
|
||||
public List<Long> approvedReportIds(final Long officeId, final LocalDate upToDate) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
|
||||
ORDER BY posting_date, savings_product_id
|
||||
""", Long.class, officeId, upToDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved reports of branches that did not open on the date — what the head office posts at its
|
||||
* own close, the branches having no close of their own that day.
|
||||
*/
|
||||
public List<Long> approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT r.id FROM m_savings_interest_posting_report r
|
||||
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
||||
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
|
||||
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
|
||||
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
||||
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
|
||||
}
|
||||
|
||||
/** Every approved report still waiting, for the nightly safety net. */
|
||||
public List<Long> approvedReportIds(final LocalDate upToDate) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE status = 'APPROVED' AND posting_date <= ?
|
||||
ORDER BY posting_date, office_id, savings_product_id
|
||||
""", Long.class, upToDate);
|
||||
}
|
||||
|
||||
/** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
|
||||
public void recordPostingFailure(final Long reportId, final String reason) {
|
||||
jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
|
||||
reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
|
||||
}
|
||||
|
||||
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
|
||||
* head office user, is every other branch (or one, with officeId). canAct says whether this user
|
||||
* may regenerate or approve the report, and blockedReason why not.
|
||||
*/
|
||||
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
||||
final String scope, final AppUser user) {
|
||||
final Long userOffice = officeOf(user);
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
final Long branch = others ? officeId : userOffice;
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||
o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
|
||||
r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
|
||||
gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
|
||||
r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (postingDate != null) {
|
||||
sql.append(" AND r.posting_date = ?");
|
||||
params.add(postingDate);
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
sql.append(" AND r.status = ?");
|
||||
params.add(status.trim().toUpperCase());
|
||||
} else {
|
||||
sql.append(" AND r.status <> 'SUPERSEDED'");
|
||||
}
|
||||
if (productId != null) {
|
||||
sql.append(" AND r.savings_product_id = ?");
|
||||
params.add(productId);
|
||||
}
|
||||
if (branch != null) {
|
||||
sql.append(" AND r.office_id = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
if (others) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(userOffice);
|
||||
}
|
||||
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
||||
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
||||
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
out.put("canAct", blocked == null);
|
||||
out.put("blockedReason", blocked);
|
||||
rows.add(out);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public Map<String, Object> detail(final Long reportId) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||
o.name AS "officeName", r.posting_date AS "postingDate",
|
||||
r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
|
||||
r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
|
||||
r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
|
||||
r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
|
||||
pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
|
||||
dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
|
||||
eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
|
||||
LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
|
||||
LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
|
||||
AND em.financial_account_type = 3 AND em.payment_type IS NULL
|
||||
LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
|
||||
WHERE r.id = ?
|
||||
""", reportId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||
"Interest posting report " + reportId + " does not exist.");
|
||||
}
|
||||
final Map<String, Object> report = new LinkedHashMap<>(found.get(0));
|
||||
report.put("lines", jdbcTemplate.queryForList("""
|
||||
SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
|
||||
l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
|
||||
l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
|
||||
FROM m_savings_interest_posting_report_line l
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.account_no
|
||||
""", reportId));
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each savings account has earned so far in its current cycle — what it would be paid if
|
||||
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
|
||||
* brought up to the business date (which moves no money) so the figure includes today.
|
||||
*
|
||||
* <p>
|
||||
* A branch user sees their own branch; a head office user every branch, or one with officeId.
|
||||
*/
|
||||
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||
|
||||
final Long userOffice = officeOf(user);
|
||||
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
|
||||
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
|
||||
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
|
||||
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE p.deposit_type_enum = 100
|
||||
AND sa.status_enum = ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
|
||||
if (branch != null) {
|
||||
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
sql.append(" ORDER BY o.name, p.name, sa.account_no");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and product interest for the head office: per report, how many customers and how much
|
||||
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
||||
* counts for that branch, product and date.
|
||||
*/
|
||||
public List<Map<String, Object>> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
|
||||
r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
|
||||
r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
|
||||
r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
WHERE r.status <> 'SUPERSEDED'
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (fromDate != null) {
|
||||
sql.append(" AND r.posting_date >= ?");
|
||||
params.add(fromDate);
|
||||
}
|
||||
if (toDate != null) {
|
||||
sql.append(" AND r.posting_date <= ?");
|
||||
params.add(toDate);
|
||||
}
|
||||
if (productId != null) {
|
||||
sql.append(" AND r.savings_product_id = ?");
|
||||
params.add(productId);
|
||||
}
|
||||
sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
||||
* before they had anything due, or failed.
|
||||
*
|
||||
* <p>
|
||||
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
|
||||
* lists the other branches — each with canAct, and where the head office may not act for it, why.
|
||||
*/
|
||||
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
||||
// part-earned interest with a date it has not reached.
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
if (postingDate.isAfter(businessDate)) {
|
||||
return out;
|
||||
}
|
||||
final Long userOffice = officeOf(user);
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
|
||||
if (others == due.officeId().equals(userOffice)) {
|
||||
continue;
|
||||
}
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("productId", due.productId());
|
||||
row.put("productName", productName(due.productId()));
|
||||
row.put("officeId", due.officeId());
|
||||
row.put("officeName", officeName(due.officeId()));
|
||||
row.put("postingDate", postingDate.toString());
|
||||
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
|
||||
row.put("canAct", blocked == null);
|
||||
row.put("blockedReason", blocked);
|
||||
out.add(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── who may act ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public boolean isHeadOffice(final Long officeId) {
|
||||
final List<Long> parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
|
||||
return !parent.isEmpty() && parent.get(0) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
|
||||
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
|
||||
*/
|
||||
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
||||
final Long rows = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_branch_day_register
|
||||
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
|
||||
""", Long.class, officeId, businessDate, businessDate);
|
||||
return rows != null && rows > 0;
|
||||
}
|
||||
|
||||
/** The branches, other than those given, that still have a day open on or before the date. */
|
||||
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.closed_at IS NULL AND r.business_date <= ?
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(businessDate));
|
||||
for (final Long exclude : excludeOfficeIds) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(exclude);
|
||||
}
|
||||
sql.append(" ORDER BY o.name");
|
||||
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Null where the user may generate or approve the branch's report, otherwise why not.
|
||||
*
|
||||
* <p>
|
||||
* A branch always acts for itself. The head office acts for another branch only when that branch
|
||||
* has not opened at all, and only once every branch that did open has closed — the head office's
|
||||
* own close being the last of the day, so nothing is taken out of a branch that may still open.
|
||||
*/
|
||||
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
if (userOffice == null) {
|
||||
return "No branch is set for this user.";
|
||||
}
|
||||
if (userOffice.equals(reportOffice)) {
|
||||
return null;
|
||||
}
|
||||
final String branch = officeName(reportOffice);
|
||||
if (!isHeadOffice(userOffice)) {
|
||||
return "Only " + branch + " can generate or approve its own interest posting report.";
|
||||
}
|
||||
if (branchOpenedOn(reportOffice, businessDate)) {
|
||||
return branch + " is open — it generates and approves its own report.";
|
||||
}
|
||||
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
|
||||
+ String.join(", ", stillOpen) + ".";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
|
||||
}
|
||||
|
||||
/** @return true where the head office is acting on a branch's behalf */
|
||||
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
||||
final Long userOffice = officeOf(user);
|
||||
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
if (blocked != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
|
||||
}
|
||||
return !reportOffice.equals(userOffice);
|
||||
}
|
||||
|
||||
private String officeName(final Long officeId) {
|
||||
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||
}
|
||||
|
||||
private static Long officeOf(final AppUser user) {
|
||||
return user == null || user.getOffice() == null ? null : user.getOffice().getId();
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Branch and product pairs with interest due on or before the date: a savings product on the
|
||||
* daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
|
||||
* accounts belonging to that branch.
|
||||
*/
|
||||
private List<DueKey> dueKeys(final LocalDate postingDate, final Long officeId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
|
||||
WHERE p.deposit_type_enum = 100
|
||||
AND sa.status_enum = ?
|
||||
AND d.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE, postingDate));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||
params.add(officeId);
|
||||
}
|
||||
sql.append(" ORDER BY 2, 1");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
|
||||
.map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
|
||||
final List<Long> ids = jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||
""", Long.class, productId, officeId, postingDate);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private String productName(final Long productId) {
|
||||
return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
|
||||
}
|
||||
|
||||
/** Runs inside the caller's transaction. */
|
||||
private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
|
||||
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||
SELECT id, status FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||
FOR UPDATE
|
||||
""", productId, officeId, postingDate);
|
||||
for (final Map<String, Object> report : previous) {
|
||||
if (POSTED.equals(report.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
|
||||
"Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
|
||||
+ " regenerated.");
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
|
||||
""", productId, officeId, postingDate);
|
||||
|
||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest,
|
||||
sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND sa.status_enum = ?
|
||||
AND COALESCE(c.office_id, g.office_id) = ?
|
||||
ORDER BY d.savings_account_id
|
||||
""", productId, postingDate, ACTIVE, officeId);
|
||||
if (due.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
|
||||
"No interest is due for this product in this branch on " + postingDate + ".");
|
||||
}
|
||||
|
||||
// The GL accounts as the product maps them now; a product missing one is refused here, before
|
||||
// anybody approves figures that could never be posted.
|
||||
final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
|
||||
|
||||
// Round each customer's share, then make the rounded shares add up to the rounded pool: the
|
||||
// residual goes to the largest balance, where a paisa is proportionally smallest.
|
||||
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||
BigDecimal roundedTotal = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
||||
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
row.put("rounded_interest", rounded);
|
||||
grossTotal = grossTotal.add(gross);
|
||||
roundedTotal = roundedTotal.add(rounded);
|
||||
}
|
||||
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final BigDecimal residual = pooled.subtract(roundedTotal);
|
||||
if (residual.signum() != 0) {
|
||||
final Map<String, Object> largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance")))
|
||||
.orElseThrow();
|
||||
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
||||
}
|
||||
|
||||
final Long reportId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_savings_interest_posting_report
|
||||
(savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
|
||||
paid_gl_account_id, generation_mode, generated_by, generated_at)
|
||||
VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
|
||||
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_interest_posting_report_line
|
||||
(report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
|
||||
balance_after, ledger_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
|
||||
before, before.add(interest), row.get("ledger_id"));
|
||||
}
|
||||
|
||||
log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
|
||||
postingDate, mode, pooled, due.size());
|
||||
return reportId;
|
||||
}
|
||||
}
|
||||
+240
-216
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||
* holds the figure that would say so.
|
||||
*
|
||||
* This settles the debt the accrual has already recognised: the product's total moves out of the
|
||||
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
|
||||
* and only then to the members:
|
||||
* The product's total is raised as an expense and credited to the liability the product names as its
|
||||
* Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
|
||||
* then to the members:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Interest Payable (liability, from the product)
|
||||
* Cr Savings Interest Paid on its way out
|
||||
* Dr Interest On Savings (expense, from the product) cash-accounted products only
|
||||
* Cr Savings Interest Payable (liability, from the product)
|
||||
*
|
||||
* Dr Savings Interest Payable
|
||||
* Cr Savings Interest Paid (liability clearing) on its way out
|
||||
*
|
||||
* Dr Savings Interest Paid (per member)
|
||||
* Cr Savings Control (per member, from the product) in the member's hands
|
||||
* </pre>
|
||||
*
|
||||
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
|
||||
* this is the settlement of what that recognition already booked - raising the expense again at
|
||||
* payout would put the same interest on the P&L twice.
|
||||
* The payable and paid accounts each close the posting at nil, so the only lasting effect is the
|
||||
* expense against the members' balances, and a liability report reading the payable is unaffected.
|
||||
* The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
|
||||
*
|
||||
* Every account named above is read from the product's own GL configuration; none is written into
|
||||
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
||||
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
|
||||
* Accounting tab, which is where a bank picks the account interest is owed from.
|
||||
*/
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
||||
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
|
||||
private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
|
||||
/** m_savings_product.accounting_type for cash-based accounting. */
|
||||
private static final int ACCOUNTING_TYPE_CASH = 2;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The payable and paid accounts a product's interest passes through. */
|
||||
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
|
||||
|
||||
/**
|
||||
* Posts one product's interest for one posting date.
|
||||
*
|
||||
* @return the amount distributed, or zero if there was nothing to do
|
||||
* The payable and paid accounts, as the product maps them now.
|
||||
*
|
||||
* <p>
|
||||
* REQUIRED, not REQUIRES_NEW.
|
||||
* The product's Accounting tab is the authority: the standard Interest Payable row first, then
|
||||
* the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
|
||||
* back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
|
||||
* leaves the two-legged posting a product without these mappings has always had.
|
||||
*/
|
||||
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
|
||||
// All three are required. Falling back to Savings Control when one was left blank posted the
|
||||
// interest straight from expense to the member with no payable or paid leg at all, and nobody
|
||||
// could tell from the entries that the product had simply lost its mappings.
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
|
||||
final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
|
||||
if (payableGl == null || paidGl == null) {
|
||||
final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
|
||||
productId);
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
|
||||
+ productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
|
||||
+ " GL mapped. Set it under the product's Accounting and generate the report again.");
|
||||
}
|
||||
return new PostingGlAccounts(payableGl, paidGl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts one approved interest posting report, paying each account exactly what was approved.
|
||||
*
|
||||
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
|
||||
* the orchestrator calls this outside any transaction of its own, so each product opens and
|
||||
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
|
||||
* running and waits for locks that transaction may hold. The accrual step immediately before
|
||||
* writes every account's ledger row, and this method reads and updates those same rows: with
|
||||
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
|
||||
* and the job hung with nothing in the log and its currently_running flag left set.
|
||||
* <p>
|
||||
* A report belongs to one branch and one product, and every leg it raises is in that branch, so
|
||||
* the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil
|
||||
* in its own books:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Interest On Savings (branch total) → Cr Savings Interest Payable
|
||||
* Dr Savings Interest Payable → Cr Savings Interest Paid
|
||||
* Dr Savings Interest Paid (once) → Cr each customer of the branch
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* Nothing is recomputed. The report's lines are the authority: interest that accrued after the
|
||||
* report was generated is not added, because the approver signed off on these figures and not on
|
||||
* others. The payable and paid accounts are those recorded on the report when it was generated.
|
||||
*
|
||||
* <p>
|
||||
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
|
||||
* branch that was shut on its posting date posts when it next closes, into a day that is still
|
||||
* open, and the narration names the cycle the interest is for.
|
||||
*
|
||||
* <p>
|
||||
* The whole report settles in one transaction, so a failure leaves nobody half paid and the report
|
||||
* still APPROVED for the next attempt.
|
||||
*
|
||||
* @return the amount distributed
|
||||
*/
|
||||
@Transactional
|
||||
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
||||
final Long completed = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
||||
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
|
||||
""", Long.class, productId, postingDate);
|
||||
if (completed != null && completed > 0) {
|
||||
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
|
||||
return BigDecimal.ZERO;
|
||||
public BigDecimal postReport(final long reportId) {
|
||||
final List<Map<String, Object>> reports = jdbcTemplate.queryForList("""
|
||||
SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
|
||||
paid_gl_account_id
|
||||
FROM m_savings_interest_posting_report
|
||||
WHERE id = ?
|
||||
FOR UPDATE
|
||||
""", reportId);
|
||||
if (reports.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
|
||||
}
|
||||
final Map<String, Object> report = reports.get(0);
|
||||
if (!"APPROVED".equals(report.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
|
||||
}
|
||||
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||
final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
|
||||
final List<Map<String, Object>> lines = jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
|
||||
sa.currency_code
|
||||
FROM m_savings_interest_posting_report_line l
|
||||
JOIN m_savings_account sa ON sa.id = l.savings_account_id
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.savings_account_id
|
||||
""", reportId);
|
||||
if (lines.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
|
||||
}
|
||||
final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
|
||||
: ((Number) lines.get(0).get("office_id")).longValue();
|
||||
|
||||
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
|
||||
|
||||
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null
|
||||
? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||
: current.payableGl();
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||
: current.paidGl();
|
||||
|
||||
final String currency = (String) lines.get(0).get("currency_code");
|
||||
// One per report, and deterministic: a retried posting carries the same reference as the legs a
|
||||
// failed attempt rolled back, and two branches posting the same product never share one.
|
||||
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
|
||||
final String narration = "Savings interest for cycle ending " + cycleDate;
|
||||
|
||||
// Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
|
||||
// credits it on a cash-accounted product — the daily accrual writes no journal entry. An
|
||||
// accrual-accounted product has already done this through its accrual postings.
|
||||
if (isCashAccounted(productId)) {
|
||||
final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||
journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Every account whose posting date has arrived or passed and has not yet been paid.
|
||||
//
|
||||
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
|
||||
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
|
||||
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
|
||||
// run on was never paid at all — the cycle simply rolled over with the interest carried into
|
||||
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
|
||||
// to behave when a night is missed.
|
||||
//
|
||||
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
|
||||
// somebody a second time.
|
||||
//
|
||||
// A savings account has no office of its own: it belongs to a client, or to a group when
|
||||
// there is no client, and the office is theirs. The journal entries have to be raised
|
||||
// against that office or they land in the wrong branch's books.
|
||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
|
||||
d.cycle_cumulative_interest AS gross_interest,
|
||||
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
|
||||
sa.currency_digits
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||
ORDER BY d.savings_account_id
|
||||
""", productId, postingDate);
|
||||
|
||||
if (due.isEmpty()) {
|
||||
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final long runId = openRun(productId, postingDate, due.size());
|
||||
|
||||
// Round each member's share, then make the rounded shares add up. The residual is the
|
||||
// difference between what the pool holds and what the members would otherwise receive.
|
||||
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||
BigDecimal roundedTotal = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
||||
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
row.put("rounded_interest", rounded);
|
||||
grossTotal = grossTotal.add(gross);
|
||||
roundedTotal = roundedTotal.add(rounded);
|
||||
}
|
||||
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final BigDecimal residual = pooled.subtract(roundedTotal);
|
||||
|
||||
if (residual.compareTo(BigDecimal.ZERO) != 0) {
|
||||
// To the largest balance: the account where a paisa is proportionally smallest.
|
||||
final Map<String, Object> largest = due.stream()
|
||||
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
|
||||
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
||||
log.info("Savings interest posting: residual {} assigned to account {}", residual,
|
||||
largest.get("savings_account_id"));
|
||||
}
|
||||
|
||||
// The three accounts the bill passes through on its way to the members. Every one is read
|
||||
// from the product's own configuration, so a bank on its own chart of accounts is posted to
|
||||
// the accounts it chose. No GL code is named in this class.
|
||||
//
|
||||
// The product's Accounting tab is the authority, and it is consulted first for all three:
|
||||
// Interest On Savings for the expense, and the standard Interest Payable row for the
|
||||
// payable. Those are the rows a bank actually fills in on the product screen.
|
||||
//
|
||||
// The reserved names in the product's other GL mappings are consulted only after that, as a
|
||||
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
|
||||
// which has no standard row of its own.
|
||||
//
|
||||
// Where a product sets none of them the bill still has somewhere to go: the payable falls
|
||||
// back to the product's Savings Control (interest owed is a liability either way), and the
|
||||
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
|
||||
// posting a product without these mappings has always had.
|
||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
||||
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
||||
|
||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||
final String currency = (String) due.get(0).get("currency_code");
|
||||
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
|
||||
// and the resumed legs have to carry the same reference as the legs already posted, or one
|
||||
// interest posting ends up split across two transaction ids.
|
||||
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
|
||||
productId);
|
||||
|
||||
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
||||
// account the payout is made from, so what is still merely owed and what is on its way to
|
||||
// the members are two separate balances rather than one.
|
||||
//
|
||||
// The interest expense itself is not raised here. It is recognised as the interest accrues,
|
||||
// day by day, and posting is the settlement of that recognised debt — raising the expense
|
||||
// again at payout would count the same interest twice on the P&L.
|
||||
//
|
||||
// Skipped where the product names no separate paid account: both legs then resolve to the
|
||||
// payable and this would debit and credit one account for the same amount, saying nothing.
|
||||
final boolean payableIsPaid = payableGl.equals(paidGl);
|
||||
final boolean stage1Applies = !payableIsPaid;
|
||||
// Stage 1: the total moves out of the payable and into the account the payout is made from.
|
||||
// Skipped where both are the same account, which would debit and credit it for nothing.
|
||||
final boolean stage1Applies = !payableGl.equals(paidGl);
|
||||
if (stage1Applies) {
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
||||
journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
|
||||
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
|
||||
// from there instead — debiting the paid account would take the money out of an account
|
||||
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
|
||||
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
||||
|
||||
// Stage 2: out to the members, one at a time.
|
||||
// Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
|
||||
// carries the report's one transaction id, and every credit names its account and interest
|
||||
// transaction, so who was paid what reads back from the journal alone.
|
||||
journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
BigDecimal distributed = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
final long accountId = ((Number) row.get("savings_account_id")).longValue();
|
||||
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
|
||||
// The member's own branch, which need not be the branch the pool was raised in.
|
||||
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
|
||||
|
||||
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||
newBalance);
|
||||
|
||||
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
|
||||
accountId);
|
||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||
accountId, transactionId);
|
||||
|
||||
// closing_balance is raised here because the interest transaction has just been written
|
||||
// and the accrual for this day has already run: nothing else will fold it in before the
|
||||
// next day reads this row as its opening.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_daily_interest
|
||||
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||
closing_balance = closing_balance + ?
|
||||
WHERE id = ?
|
||||
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
||||
|
||||
// Both interest columns move together, and that is not a duplicate.
|
||||
//
|
||||
// total_interest_earned_derived is the interest the account has earned to date;
|
||||
// total_interest_posted_derived is how much of that has been paid into the balance.
|
||||
// Screens and validate()'s own check read the unposted interest as earned - posted, so
|
||||
// raising only `posted` drives that difference negative: an account paid 26.52 with
|
||||
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
|
||||
// showed exactly that. The accrual writes pending interest to the ledger and never to
|
||||
// `earned`, so this posting is the only place `earned` is realised.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_account
|
||||
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
||||
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, accountId);
|
||||
|
||||
distributed = distributed.add(amount);
|
||||
for (final Map<String, Object> line : lines) {
|
||||
distributed = distributed
|
||||
.add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
|
||||
}
|
||||
|
||||
// The whole point of the two control accounts: if this does not hold, nothing commits.
|
||||
// If the credits do not add up to the debit, nothing commits.
|
||||
if (pooled.compareTo(distributed) != 0) {
|
||||
throw new IllegalStateException(String.format(
|
||||
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
|
||||
productId, postingDate, pooled, distributed));
|
||||
"Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
|
||||
}
|
||||
|
||||
return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* One customer's credit: the interest transaction, the Savings Control credit naming the account,
|
||||
* and the account, ledger and report line brought up to date. The branch debit is raised by the
|
||||
* caller.
|
||||
*
|
||||
* @return the amount credited, zero for a line with nothing to pay
|
||||
*/
|
||||
private BigDecimal payMember(final Map<String, Object> line, final long productId, final long officeId,
|
||||
final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
|
||||
final String narration, final long runId) {
|
||||
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
final long accountId = ((Number) line.get("savings_account_id")).longValue();
|
||||
|
||||
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
|
||||
// payment, so this holds even where one account has two cycles posted on the same day.
|
||||
if (line.get("savings_transaction_id") != null) {
|
||||
throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
|
||||
}
|
||||
|
||||
// The balance as it stands now, not as the report showed it: money moved since the report
|
||||
// was generated, and the transaction's running balance has to follow the account.
|
||||
final BigDecimal currentBalance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
|
||||
final BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
|
||||
|
||||
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||
journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
|
||||
|
||||
// Settles the ledger row where it still holds the cycle the report was drawn from. Where the
|
||||
// nightly run has already moved it into the next cycle — a branch that was shut on its posting
|
||||
// date and posts days later — that row is the new cycle's and must not be marked paid; it only
|
||||
// takes the money, because the next day's accrual opens from its closing balance.
|
||||
final int settled = jdbcTemplate.update("""
|
||||
UPDATE m_savings_daily_interest
|
||||
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||
closing_balance = closing_balance + ?
|
||||
WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
|
||||
""", amount, transactionId, runId, amount, accountId, cycleDate);
|
||||
if (settled == 0) {
|
||||
jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
|
||||
amount, accountId);
|
||||
}
|
||||
|
||||
// Earned is only ever raised to what has now been posted, never added to: the engine may
|
||||
// already have counted this interest as earned when the account was last recalculated,
|
||||
// and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
|
||||
// unposted interest as earned - posted, so earned must not fall below posted either.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_account
|
||||
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
||||
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
|
||||
COALESCE(total_interest_posted_derived, 0) + ?)
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, accountId);
|
||||
|
||||
jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
|
||||
transactionId, ((Number) line.get("line_id")).longValue());
|
||||
|
||||
return amount;
|
||||
}
|
||||
|
||||
private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
|
||||
final BigDecimal distributed, final long runId, final int accountCount) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
|
||||
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", pooled, distributed, residual, runId);
|
||||
""", pooled, distributed, runId);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report
|
||||
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
|
||||
WHERE id = ?
|
||||
""", runId, reportId);
|
||||
|
||||
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
|
||||
postingDate, distributed, due.size(), residual);
|
||||
log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
|
||||
cycleDate, distributed, accountCount);
|
||||
return distributed;
|
||||
}
|
||||
|
||||
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
|
||||
/** One run per report. A retry of the same report reuses its run. */
|
||||
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
|
||||
final int accountCount) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_interest_posting_run
|
||||
(savings_product_id, posting_date, account_count, status)
|
||||
VALUES (?, ?, ?, 'PENDING')
|
||||
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
|
||||
(report_id, savings_product_id, office_id, posting_date, account_count, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'PENDING')
|
||||
ON CONFLICT (report_id) DO UPDATE SET
|
||||
account_count = EXCLUDED.account_count, status = 'PENDING',
|
||||
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
||||
""", productId, postingDate, accountCount);
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT id FROM m_savings_interest_posting_run
|
||||
WHERE savings_product_id = ? AND posting_date = ?
|
||||
""", Long.class, productId, postingDate);
|
||||
""", reportId, productId, officeId, cycleDate, accountCount);
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
|
||||
}
|
||||
|
||||
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
||||
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
|
||||
""", Long.class, accountId, date, INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/** The member's own leg: the product's Savings Control account, credited per account. */
|
||||
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
||||
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
||||
final long transactionId) {
|
||||
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
||||
}
|
||||
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
|
||||
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* One journal entry leg.
|
||||
*
|
||||
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
|
||||
*/
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||
final Long transactionId) {
|
||||
final Long transactionId, final String narration) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
|
||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
|
||||
debit ? "Savings interest posting" : "Savings interest posted",
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||
systemUserId(), systemUserId(), date);
|
||||
}
|
||||
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
|
||||
}
|
||||
|
||||
/** The first configured account of those given, in order of preference. */
|
||||
private boolean isCashAccounted(final long productId) {
|
||||
final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
|
||||
Integer.class, productId);
|
||||
return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
|
||||
}
|
||||
|
||||
private static Long firstNonNull(final Long... candidates) {
|
||||
for (final Long candidate : candidates) {
|
||||
if (candidate != null) {
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.dailyinterest.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The savings interest posting reports, per branch and product: list, detail, generate, approve, the
|
||||
* day-close check, and the head office's branch-wise summary.
|
||||
*
|
||||
* <p>
|
||||
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
|
||||
* day, so there is exactly one way money leaves the payable.
|
||||
*/
|
||||
@Path("/v1/savingsinterestpostingreports")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class SavingsInterestPostingReportApiResource {
|
||||
|
||||
private static final String RESOURCE = "SAVINGSINTERESTREPORT";
|
||||
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||
@GET
|
||||
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
|
||||
@QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
|
||||
@GET
|
||||
@Path("/context")
|
||||
public String userContext() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
final Long officeId = user.getOffice().getId();
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("officeId", officeId);
|
||||
result.put("officeName", user.getOffice().getName());
|
||||
result.put("headOffice", reportService.isHeadOffice(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
||||
@GET
|
||||
@Path("/due-without-report")
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops a branch closing the business day: its own reports due and not yet approved. The
|
||||
* day-close screen reads it to warn before the close is attempted.
|
||||
*/
|
||||
@GET
|
||||
@Path("/day-close-check")
|
||||
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final List<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", businessDate.toString());
|
||||
result.put("canClose", blocking.isEmpty());
|
||||
result.put("pendingProducts", blocking);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
|
||||
@GET
|
||||
@Path("/preview")
|
||||
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
||||
@GET
|
||||
@Path("/summary")
|
||||
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("productId") final Long productId) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
if (!reportService.isHeadOffice(user.getOffice().getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
|
||||
"The branch-wise interest summary is for the head office.");
|
||||
}
|
||||
return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{reportId}")
|
||||
public String detail(@PathParam("reportId") final Long reportId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final Map<String, Object> report = plain(reportService.detail(reportId));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
|
||||
report.put("lines", lines.stream().map(this::plain).toList());
|
||||
return gson.toJson(report);
|
||||
}
|
||||
|
||||
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String command(@QueryParam("command") final String command, final String body) {
|
||||
if (!"regenerate".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||
"Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
|
||||
final var root = fromJsonHelper.parse(body);
|
||||
final Long productId = fromJsonHelper.extractLongNamed("productId", root);
|
||||
final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
|
||||
if (productId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
|
||||
}
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
final Long reportId = reportService.regenerate(productId, officeId, date, user);
|
||||
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
|
||||
}
|
||||
|
||||
/** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
|
||||
@POST
|
||||
@Path("/{reportId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
|
||||
if (!"approve".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||
"Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
|
||||
reportService.approve(reportId, user);
|
||||
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw.trim());
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
|
||||
"Dates are expected as yyyy-MM-dd, not " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
|
||||
private Map<String, Object> plain(final Map<String, Object> row) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
row.forEach((key, value) -> {
|
||||
if (value instanceof java.sql.Date d) {
|
||||
out.put(key, d.toLocalDate().toString());
|
||||
} else if (value instanceof Timestamp t) {
|
||||
out.put(key, t.toLocalDateTime().toString());
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class SavingsInterestPostingReportConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||
return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||
return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Noon on the business date: generates the interest posting report for every savings product whose
|
||||
* interest falls due, so it can be reviewed and approved before the branches close the day.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class SavingsInterestPostingReportTasklet implements Tasklet {
|
||||
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
reportService.generateDue(DateUtils.getBusinessLocalDate());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator {
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
||||
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
|
||||
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
|
||||
DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
|
||||
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
@@ -245,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
|
||||
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
|
||||
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
|
||||
} else if (accountOnClosureType.isTransferToGl()) {
|
||||
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
|
||||
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
|
||||
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
|
||||
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"transfer.to.gl.not.allowed",
|
||||
"Transfer to a GL account is only supported for fixed-deposit premature closure");
|
||||
}
|
||||
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
|
||||
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
|
||||
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
|
||||
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
|
||||
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
|
||||
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
|
||||
// and is refused here as well -- otherwise a direct API call could still take the cash route.
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"withdraw.deposit.not.allowed",
|
||||
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+141
@@ -19,10 +19,14 @@
|
||||
package org.apache.fineract.portfolio.savings.domain;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
@@ -36,6 +40,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||
@@ -46,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
@@ -65,6 +71,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
@@ -72,7 +79,9 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -93,6 +102,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
|
||||
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
|
||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -267,9 +279,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
} else {
|
||||
// Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -390,10 +406,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
account.updateClosedStatus();
|
||||
account.updateOnAccountClosureStatus(onClosureType);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
// if(!processMaturityInstructionOnly)
|
||||
@@ -473,9 +492,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -812,6 +834,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
Long savingsTransactionId = null;
|
||||
|
||||
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
|
||||
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
|
||||
// fresh here since the preview and the actual submit are two separate requests.
|
||||
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
|
||||
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
|
||||
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
|
||||
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
|
||||
// closure always matches what the preview showed for the same reason.
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
|
||||
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
|
||||
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
|
||||
|
||||
// post interest
|
||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
@@ -831,13 +865,34 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else if (closureType.isTransferToGl()) {
|
||||
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
|
||||
// Taken before the proceeds are read, so the GL receives the payout net of TDS.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"GL");
|
||||
final BigDecimal proceeds = account.getAccountBalance();
|
||||
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
|
||||
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
|
||||
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
|
||||
// net posting as Dr Savings Control / Cr chosen GL.
|
||||
final boolean isInternalTransfer = true;
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
|
||||
isRegularTransaction, isInternalTransfer);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
|
||||
command.stringValueOfParameterNamed(transferDescriptionParamName));
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"CASH");
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
|
||||
// accounted for or the postJournalEntries() call below posts it a second time.
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -848,6 +903,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
|
||||
* Dr the Liability Transfer account / Cr that GL account.
|
||||
* <p>
|
||||
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
|
||||
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
|
||||
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
|
||||
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
|
||||
* reconciles exactly like any other manual entry.
|
||||
*/
|
||||
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
|
||||
final LocalDate closedDate, final String description) {
|
||||
|
||||
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
|
||||
|
||||
final JsonObject debitEntry = new JsonObject();
|
||||
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
|
||||
debitEntry.addProperty("amount", amount);
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debitEntry);
|
||||
|
||||
final JsonObject creditEntry = new JsonObject();
|
||||
creditEntry.addProperty("glAccountId", toGlAccountId);
|
||||
creditEntry.addProperty("amount", amount);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(creditEntry);
|
||||
|
||||
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
|
||||
if (description != null && !description.isBlank()) {
|
||||
comments.append(" -- ").append(description);
|
||||
}
|
||||
|
||||
final String dateFormat = "dd MMMM yyyy";
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", account.officeId());
|
||||
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
|
||||
payload.addProperty("dateFormat", dateFormat);
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", account.getCurrency().getCode());
|
||||
payload.addProperty("comments", comments.toString());
|
||||
payload.add("debits", debits);
|
||||
payload.add("credits", credits);
|
||||
|
||||
final String json = payload.toString();
|
||||
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
|
||||
* to clear it.
|
||||
* <p>
|
||||
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
|
||||
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
|
||||
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
|
||||
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
|
||||
* collapse onto whichever account that mapping names.
|
||||
* <p>
|
||||
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
|
||||
* closure's money somewhere nobody chose.
|
||||
*/
|
||||
private Long liabilityTransferGlAccountId() {
|
||||
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
|
||||
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
if (glAccountIds.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
|
||||
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
|
||||
+ " proceeds cannot be routed to a GL account. Map it first.",
|
||||
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
}
|
||||
return glAccountIds.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
||||
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
||||
@@ -1023,10 +1153,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
}
|
||||
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
|
||||
if (alreadyMatured) {
|
||||
account.close(user, command, changes);
|
||||
@@ -1084,9 +1217,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(closureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -1111,6 +1247,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||
}
|
||||
|
||||
/** How a closure that does not go to savings paid out, for the TDS tracking record. */
|
||||
private static String directPayoutKind(final DepositAccountOnClosureType closureType) {
|
||||
return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH";
|
||||
}
|
||||
|
||||
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
||||
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
|
||||
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
|
||||
|
||||
+100
-22
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
protected InterestRateChartAssembler chartAssembler;
|
||||
@Transient
|
||||
private ConfigurationDomainService configurationDomainService;
|
||||
/**
|
||||
* Set once per request, immediately before a premature-closure preview or the real closure, from the
|
||||
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
|
||||
* a one-time decision for this specific closure rather than a product/account setting. The product's
|
||||
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
|
||||
* every other premature closure of this same product still applies its penalty as normal; this only
|
||||
* suppresses it for the one request that explicitly asked to waive it.
|
||||
*/
|
||||
@Transient
|
||||
private boolean waivePreClosurePenaltyForThisRequest;
|
||||
|
||||
protected FixedDepositAccount() {
|
||||
//
|
||||
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
if (this.chart != null) {
|
||||
boolean applyPreMaturePenalty = false;
|
||||
BigDecimal penalInterest = BigDecimal.ZERO;
|
||||
LocalDate depositCloseDate = calculateMaturityDate();
|
||||
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
|
||||
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
|
||||
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
|
||||
// overload), so a closure's freshly-recomputed total always matches what the account was
|
||||
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
|
||||
// simple, one rate for the account's whole life.
|
||||
final LocalDate depositCloseDate = calculateMaturityDate();
|
||||
if (isPreMatureClosure) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
|
||||
applyPreMaturePenalty = true;
|
||||
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
|
||||
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
|
||||
.depositPreClosureDetail().preClosurePenalInterestOnType();
|
||||
if (preClosurePenalInterestOnType.isWholeTerm()) {
|
||||
depositCloseDate = interestCalculatedUpto();
|
||||
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
|
||||
depositCloseDate = interestPostingUpToDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
boolean isUserPosting = false;
|
||||
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
|
||||
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
||||
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
|
||||
|
||||
// RBI audit trail: record why, not just that, this closure happened -- see
|
||||
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
|
||||
// which resolves to INVALID/null here rather than a misleading "Normal".
|
||||
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
|
||||
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
|
||||
|
||||
/*
|
||||
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
|
||||
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
|
||||
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
|
||||
}
|
||||
|
||||
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
|
||||
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
|
||||
* same interest calculation this waives a part of. */
|
||||
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
|
||||
this.waivePreClosurePenaltyForThisRequest = waive;
|
||||
}
|
||||
|
||||
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
||||
|
||||
@@ -807,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
final Integer financialYearBeginningMonth) {
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
|
||||
try {
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
} finally {
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
}
|
||||
|
||||
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
|
||||
@@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest already posted for the term currently running, NOT the account's whole lifetime.
|
||||
* <p>
|
||||
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
|
||||
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
|
||||
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
|
||||
* premature closure of the CURRENT term subtracted it from the maturity amount
|
||||
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
|
||||
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
|
||||
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
|
||||
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
|
||||
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
|
||||
* the term restarted belongs to a term that has already been settled.
|
||||
*/
|
||||
private Money totalInterestPosted() {
|
||||
final boolean renewedTerm = isRenewedTerm();
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
Money interestPostedToDate = Money.zero(this.currency);
|
||||
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||
if (transaction.isInterestPostingAndNotReversed()) {
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
if (!transaction.isInterestPostingAndNotReversed()) {
|
||||
continue;
|
||||
}
|
||||
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
|
||||
// previous term's closing interest posting is dated the SAME day the new term starts, so an
|
||||
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
|
||||
// the old term's entire interest subtracted from a maturity amount it was never part of.
|
||||
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
continue;
|
||||
}
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
}
|
||||
|
||||
return interestPostedToDate;
|
||||
@@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* A renewed term's opening balance is seeded straight to the term's own principal (see
|
||||
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
|
||||
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
|
||||
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
|
||||
* and the new one opened in the same stroke), so without this filter it still falls inside the new
|
||||
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
|
||||
* understating the very first day's balance by the old term's entire payout, which silently corrupts
|
||||
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
|
||||
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
|
||||
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
|
||||
*/
|
||||
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
|
||||
final List<SavingsAccountTransaction> transactions) {
|
||||
if (!isRenewedTerm()) {
|
||||
return transactions;
|
||||
}
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
|
||||
for (final SavingsAccountTransaction transaction : transactions) {
|
||||
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
scoped.add(transaction);
|
||||
}
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
private LocalDate interestCalculatedUpto() {
|
||||
LocalDate uptoMaturityDate = calculateMaturityDate();
|
||||
if (uptoMaturityDate != null) {
|
||||
|
||||
+13
-8
@@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount {
|
||||
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
final Integer financialYearBeginningMonth) {
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
|
||||
try {
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
}
|
||||
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
|
||||
return interestOnMaturity;
|
||||
} finally {
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
|
||||
return interestOnMaturity;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-10
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
|
||||
+38
-7
@@ -19,8 +19,11 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
|
||||
@@ -66,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
|
||||
|
||||
DepositAccountData accountData = null;
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
|
||||
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
|
||||
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
|
||||
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
|
||||
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
|
||||
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
|
||||
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
|
||||
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
|
||||
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL }
|
||||
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
|
||||
@@ -77,13 +89,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
// calculate interest before one day of closure date
|
||||
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
|
||||
final boolean isPreMatureClosure = true;
|
||||
// Preview-time only -- the real closure re-reads this same box from its own request when it
|
||||
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
|
||||
// maker previewing with the box checked and then submitting without it (or vice versa) gets
|
||||
// exactly what they last confirmed, not what an earlier preview computed.
|
||||
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
|
||||
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
|
||||
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
|
||||
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
|
||||
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
|
||||
// the preview a maker sees always matches what the actual closure will compute.
|
||||
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
|
||||
element);
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
|
||||
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
|
||||
|
||||
if (depositAccountType.isFixedDeposit()) {
|
||||
final FixedDepositAccount fd = (FixedDepositAccount) account;
|
||||
accountData = FixedDepositAccountData.preClosureDetails(
|
||||
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
||||
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
|
||||
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
|
||||
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
|
||||
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
|
||||
// rather than re-deriving it, to show the maker exactly what applied.
|
||||
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
|
||||
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
||||
accountData = RecurringDepositAccountData.preClosureDetails(
|
||||
|
||||
+5
-1
@@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
|
||||
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
|
||||
depositAccountType.getValue());
|
||||
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
|
||||
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
|
||||
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
|
||||
// normal Close and Payment screens -- would only get it refused by validateClosing.
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
|
||||
.toArray(DepositAccountOnClosureType[]::new));
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
|
||||
+34
-1
@@ -19,10 +19,13 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingEnumerations;
|
||||
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
}
|
||||
}
|
||||
|
||||
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
|
||||
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
|
||||
|
||||
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
|
||||
}
|
||||
|
||||
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
|
||||
}
|
||||
|
||||
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
|
||||
|
||||
private final String schemaSql;
|
||||
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
|
||||
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
|
||||
sqlBuilder.append("sp.accounting_type as accountingType, ");
|
||||
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
|
||||
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
|
||||
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
|
||||
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
|
||||
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
|
||||
}
|
||||
|
||||
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
|
||||
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
|
||||
taxGroupData);
|
||||
final String otherGlMappingsJson = rs.getString("otherGlMappings");
|
||||
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
|
||||
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
return false;
|
||||
}
|
||||
|
||||
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
|
||||
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
|
||||
}
|
||||
|
||||
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
|
||||
return savingsAccountData.isTransferInterestToLinkedAccount()
|
||||
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
|
||||
}
|
||||
|
||||
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
|
||||
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
if (interestTransferWithdrawalIds.isEmpty()) {
|
||||
return transactions;
|
||||
}
|
||||
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
|
||||
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
|
||||
|
||||
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
|
||||
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
|
||||
orderedNonInterestPostingTransactions.add(transaction);
|
||||
}
|
||||
}
|
||||
@@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
|
||||
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
|
||||
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
|
||||
savingsAccountData);
|
||||
// loop over transactions in reverse
|
||||
LocalDate endOfBalanceDate = interestPostingUpToDate;
|
||||
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
|
||||
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
|
||||
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
|
||||
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
|
||||
|
||||
+7
-1
@@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
|
||||
final Integer status, final Long maxSavingsId) {
|
||||
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
||||
// A product on the daily-interest timetable is paid by the pooled posting instead
|
||||
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
|
||||
// subquery so a page is never cut short by accounts that are then dropped.
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
|
||||
+2
-10
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-12
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -281,7 +281,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
|
||||
+ "m.dd_limit_amount "
|
||||
+ "from m_sponsor_dd_gl_mapping m "
|
||||
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
// Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so
|
||||
// an inner join dropped the whole row and the settings screen reopened blank.
|
||||
+ "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
+ "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id "
|
||||
+ "left join acc_gl_account cash on cash.id = m.cash_gl_account_id "
|
||||
+ "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id "
|
||||
@@ -310,7 +312,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final Long id = rs.getLong("id");
|
||||
final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null;
|
||||
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
||||
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
||||
final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null;
|
||||
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
||||
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
|
||||
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
||||
|
||||
+2
-2
@@ -52,10 +52,10 @@ public class TdsInterestApiResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* 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.JsonObject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsSql;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the
|
||||
* challans Head Office remits the deducted tax with.
|
||||
*
|
||||
* <p>
|
||||
* Both feed the deduction and the reports directly. A declaration stops the deduction from the day it
|
||||
* is accepted; a challan is what the remittance report reconciles the deducted tax against.
|
||||
*/
|
||||
@Path("/v1/tds")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class TdsRecordsApiResource {
|
||||
|
||||
private static final String DECLARATION = "TDSDECLARATION";
|
||||
private static final String CHALLAN = "TDSCHALLAN";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── declarations ───────────────────────────
|
||||
|
||||
/** A customer's declarations, every year and revoked ones included, newest year first. */
|
||||
@GET
|
||||
@Path("declarations")
|
||||
public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (clientId == null && fy == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy.");
|
||||
}
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?");
|
||||
params.add(user.getOffice().getHierarchy() + "%");
|
||||
if (clientId != null) {
|
||||
where.append(" AND d.client_id = ?");
|
||||
params.add(clientId);
|
||||
}
|
||||
if (fy != null) {
|
||||
where.append(" AND d.fy_label = ?");
|
||||
params.add(fy);
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on,
|
||||
d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason
|
||||
FROM m_tds_declaration d
|
||||
JOIN m_client c ON c.id = d.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
""" + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray());
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("clientId", r.get("client_id"));
|
||||
o.put("clientName", r.get("client_name"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("formType", r.get("form_type"));
|
||||
o.put("uin", r.get("uin"));
|
||||
o.put("acceptedOn", str(r.get("accepted_on")));
|
||||
o.put("estimatedIncome", r.get("estimated_income"));
|
||||
o.put("revoked", r.get("is_revoked"));
|
||||
o.put("revokedOn", str(r.get("revoked_on")));
|
||||
o.put("revokeReason", r.get("revoke_reason"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a declaration. One live declaration per customer per year; the form must be the one the
|
||||
* year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year
|
||||
* and not in the future, and the customer must have a PAN - a declaration without one is invalid.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createDeclaration(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final Long clientId = requiredLong(root, "clientId");
|
||||
assertInScope(user, clientId);
|
||||
final String fy = requiredText(root, "fy");
|
||||
final String formType = requiredText(root, "formType").toUpperCase();
|
||||
final LocalDate acceptedOn = requiredDate(root, "acceptedOn");
|
||||
final String uin = optionalText(root, "uin");
|
||||
final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull()
|
||||
&& !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null;
|
||||
|
||||
final List<Map<String, Object>> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?",
|
||||
fy);
|
||||
if (cfg.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured",
|
||||
"Financial year " + fy + " is not set up under Organization › TDS Configuration.");
|
||||
}
|
||||
final String yearForm = (String) cfg.get(0).get("declaration_form");
|
||||
final boolean merged = "121".equals(yearForm);
|
||||
if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year",
|
||||
merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H.");
|
||||
}
|
||||
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate fyStart = LocalDate.of(startYear, 4, 1);
|
||||
final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31);
|
||||
if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year",
|
||||
"The acceptance date must fall inside " + fy + ".");
|
||||
}
|
||||
if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future",
|
||||
"A declaration cannot be accepted on a future date.");
|
||||
}
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", clientId);
|
||||
if (!TdsSql.isWellFormedPan((String) person.get("pan"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required",
|
||||
"A declaration is valid only with the customer's PAN. Record a PAN on the KYC first.");
|
||||
}
|
||||
if (!merged) {
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd);
|
||||
if (senior != "15H".equals(formType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age",
|
||||
senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy
|
||||
+ ", so the form is 15G.");
|
||||
}
|
||||
}
|
||||
|
||||
final Integer live = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class,
|
||||
clientId, fy);
|
||||
if (live != null && live > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate",
|
||||
"This customer already has a declaration on record for " + fy + ". Revoke it first to replace it.");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income,
|
||||
is_revoked, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId());
|
||||
return declarations(clientId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws a declaration from a date. Interest credited from then on is deducted again; what was
|
||||
* paid gross while it stood stays paid gross, because it was lawful at the time.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations/{id}/revoke")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String revokeDeclaration(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
final List<Long> owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false",
|
||||
Long.class, id);
|
||||
if (owner.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + ".");
|
||||
}
|
||||
assertInScope(user, owner.get(0));
|
||||
final String reason = requiredText(root, "reason");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_tds_declaration
|
||||
SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ?
|
||||
WHERE id = ?
|
||||
""", DateUtils.getBusinessLocalDate(), reason, user.getId(), id);
|
||||
return declarations(owner.get(0), null);
|
||||
}
|
||||
|
||||
// ─────────────────────────── challans ───────────────────────────
|
||||
|
||||
/** Every challan entered for the year, reversed ones included so the trail stays visible. */
|
||||
@GET
|
||||
@Path("challans")
|
||||
public String challans(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on,
|
||||
ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks,
|
||||
ch.is_reversed
|
||||
FROM m_tds_challan ch
|
||||
JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id
|
||||
WHERE ch.fy_label = ?
|
||||
ORDER BY ch.deduction_month, ch.deposited_on, ch.id
|
||||
""", fy);
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("month", r.get("deduction_month"));
|
||||
o.put("amount", r.get("amount"));
|
||||
o.put("challanNo", r.get("challan_no"));
|
||||
o.put("bsrCode", r.get("bsr_code"));
|
||||
o.put("depositedOn", str(r.get("deposited_on")));
|
||||
o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id"));
|
||||
o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name"));
|
||||
o.put("journalTransactionId", r.get("journal_transaction_id"));
|
||||
o.put("remarks", r.get("remarks"));
|
||||
o.put("reversed", r.get("is_reversed"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD
|
||||
* interest received there. This is what the challans must add up to.
|
||||
*/
|
||||
@GET
|
||||
@Path("deducted")
|
||||
public String deducted(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
requireFy(fy);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.fy_label = ?
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""", fy);
|
||||
// The business date, not the wall clock: whether a challan is overdue is judged on the day the
|
||||
// books are on.
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("asAt", DateUtils.getBusinessLocalDate().toString());
|
||||
out.put("months", rows);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */
|
||||
@GET
|
||||
@Path("challans/template")
|
||||
public String challanTemplate() {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, gl_code AS "glCode", name FROM acc_gl_account
|
||||
WHERE disabled = false AND account_usage = 1 AND classification_enum = 1
|
||||
ORDER BY gl_code
|
||||
""");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("paidFromOptions", accounts);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office.
|
||||
* Only Head Office remits - the bank files one return under one TAN.
|
||||
*/
|
||||
@POST
|
||||
@Path("challans")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createChallan(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final String fy = requireFy(requiredText(root, "fy"));
|
||||
final String month = requiredText(root, "month");
|
||||
if (!month.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06.");
|
||||
}
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate monthStart = LocalDate.parse(month + "-01");
|
||||
if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + ".");
|
||||
}
|
||||
final BigDecimal amount = root.get("amount").getAsBigDecimal();
|
||||
if (amount.signum() <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero.");
|
||||
}
|
||||
final String challanNo = requiredText(root, "challanNo");
|
||||
final String bsrCode = requiredText(root, "bsrCode");
|
||||
if (!bsrCode.matches("\\d{7}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits.");
|
||||
}
|
||||
final LocalDate depositedOn = requiredDate(root, "depositedOn");
|
||||
if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future.");
|
||||
}
|
||||
final Long paidFrom = requiredLong(root, "paidFromGlAccountId");
|
||||
final String remarks = optionalText(root, "remarks");
|
||||
|
||||
final Integer dup = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false",
|
||||
Integer.class, challanNo, bsrCode, depositedOn);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record.");
|
||||
}
|
||||
|
||||
final Long tdsPayable = tdsPayableGl();
|
||||
final long officeId = user.getOffice().getId();
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18);
|
||||
final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode;
|
||||
journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId());
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on,
|
||||
paid_from_gl_account_id, office_id, journal_transaction_id, remarks,
|
||||
is_reversed, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId());
|
||||
return challans(fy);
|
||||
}
|
||||
|
||||
/** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */
|
||||
@POST
|
||||
@Path("challans/{id}/reverse")
|
||||
@Transactional
|
||||
public String reverseChallan(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan"
|
||||
+ " WHERE id = ? AND is_reversed = false",
|
||||
id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + ".");
|
||||
}
|
||||
final Map<String, Object> ch = rows.get(0);
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17);
|
||||
final String narration = "Reversal of TDS challan " + ch.get("challan_no");
|
||||
final long officeId = ((Number) ch.get("office_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) ch.get("amount");
|
||||
journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration,
|
||||
user.getId());
|
||||
jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id);
|
||||
return challans((String) ch.get("fy_label"));
|
||||
}
|
||||
|
||||
// ─────────────────────────── helpers ───────────────────────────
|
||||
|
||||
/** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final String narration, final Long userId) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||
reversed, manual_entry, office_running_balance, organization_running_balance,
|
||||
created_by, last_modified_by, created_date, lastmodified_date, created_on_utc,
|
||||
last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date);
|
||||
}
|
||||
|
||||
private Long tdsPayableGl() {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured",
|
||||
"No TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
return gl.get(0);
|
||||
}
|
||||
|
||||
private void assertHeadOffice(final AppUser user) {
|
||||
if (user.getOffice().getParent() != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only",
|
||||
"TDS is remitted by Head Office. A branch login cannot record a challan.");
|
||||
}
|
||||
}
|
||||
|
||||
/** A user may act only on customers of their own office or the offices below it. */
|
||||
private void assertInScope(final AppUser user, final Long clientId) {
|
||||
final Integer n = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id
|
||||
WHERE c.id = ? AND o.hierarchy LIKE ?
|
||||
""", Integer.class, clientId, user.getOffice().getHierarchy() + "%");
|
||||
if (n == null || n == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope",
|
||||
"Customer " + clientId + " does not belong to your office.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFy(final String fy) {
|
||||
if (fy == null || !fy.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27.");
|
||||
}
|
||||
return fy;
|
||||
}
|
||||
|
||||
private static String str(final Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static String optionalText(final JsonObject root, final String name) {
|
||||
if (!root.has(name) || root.get(name).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String v = root.get(name).getAsString().trim();
|
||||
return v.isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
private static String requiredText(final JsonObject root, final String name) {
|
||||
final String v = optionalText(root, name);
|
||||
if (v == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required.");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static Long requiredLong(final JsonObject root, final String name) {
|
||||
try {
|
||||
return Long.valueOf(requiredText(root, name));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd. */
|
||||
private static LocalDate requiredDate(final JsonObject root, final String name) {
|
||||
try {
|
||||
return LocalDate.parse(requiredText(root, name));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */
|
||||
public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn,
|
||||
BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {}
|
||||
+18
-9
@@ -19,12 +19,13 @@
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
* A customer whose deposit interest the branch must consider for deduction, together with the
|
||||
* accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
@@ -32,23 +33,31 @@ import java.util.List;
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*
|
||||
* <p>
|
||||
* The customer here is the <em>deductee</em>: a minor's deposit is listed under the guardian and a
|
||||
* joint deposit under its primary holder, because that is whose PAN the tax is deducted against.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
String branchName, List<TdsDeclarationData> declarations, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account and the interest posted to it.
|
||||
* One deposit account, the interest posted to it and the TDS actually deducted from it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
* @param clientId
|
||||
* the deductee, which is the account holder unless the account is a minor's or joint
|
||||
* @param holderClientId
|
||||
* the account's own holder, shown when it differs from the deductee
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId,
|
||||
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
|
||||
List<TdsInterestCreditData> deductions) implements Serializable {}
|
||||
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
/** One interest posting, or one TDS deduction: the date and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {}
|
||||
}
|
||||
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TDS on FD/RD interest, tracked and deducted where the interest is received.
|
||||
*
|
||||
* <p>
|
||||
* Normally that is the customer's savings account: every time money moves from a fixed or recurring
|
||||
* deposit into savings, the interest in it is recorded against the deductee (see
|
||||
* {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic
|
||||
* interest sweep is all interest; a maturity or closure payout is principal and interest together, so
|
||||
* only the interest not already received is counted. The FD and RD are not debited.
|
||||
*
|
||||
* <p>
|
||||
* When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never
|
||||
* reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full
|
||||
* reinvestment pays nothing out and is not taxed here.
|
||||
*
|
||||
* <p>
|
||||
* The rules, all for one financial year and one deductee:
|
||||
* <ol>
|
||||
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
|
||||
* is not - it is outside the charge.</li>
|
||||
* <li>A declaration shields the interest received from the day it is accepted. Interest received
|
||||
* before it stays liable.</li>
|
||||
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
|
||||
* <li>Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or
|
||||
* the higher rate without a valid PAN.</li>
|
||||
* <li>What is deducted now is that liability less everything already deducted this year, so the credit
|
||||
* that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The tax is posted as a withhold-tax transaction through a tax component whose credit account is the
|
||||
* TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the
|
||||
* account's own accounting writes Dr deposit liability / Cr TDS payable.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SavingsInterestTdsService {
|
||||
|
||||
/** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */
|
||||
public static final String TDS_COMPONENT_NAME = "TDS on deposit interest";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final TaxComponentRepository taxComponentRepository;
|
||||
private final EntityManager entityManager;
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
|
||||
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
|
||||
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called once a savings-to-savings transfer has been saved.
|
||||
*
|
||||
* @param deposit
|
||||
* the credit on the receiving savings account
|
||||
*/
|
||||
public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit,
|
||||
final AccountTransferType transferType) {
|
||||
if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) {
|
||||
return;
|
||||
}
|
||||
final DepositAccountType toType = to.depositAccountType();
|
||||
if (toType == null || !toType.isSavingsDeposit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean sweep = transferType != null && transferType.isInterestTransfer();
|
||||
final BigDecimal interest = sweep ? deposit.getAmount()
|
||||
: interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate date = deposit.getTransactionDate();
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(from, year);
|
||||
if (deductee == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year,
|
||||
date, interest);
|
||||
|
||||
final Due due = dueFor(deductee, year, date, BigDecimal.ZERO);
|
||||
if (due == null) {
|
||||
return;
|
||||
}
|
||||
final Set<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
|
||||
final Set<Long> existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds());
|
||||
final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
// Saved on its own first, as Fineract does for every new transaction: saving only the account
|
||||
// merges it, and the id lands on the merged copy - this instance stays null, which broke both
|
||||
// the deduction row and the journal entry's transaction reference.
|
||||
this.savingsAccountTransactionRepository.saveAndFlush(tds);
|
||||
this.savingsAccountRepository.saveAndFlush(to);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(
|
||||
to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false));
|
||||
recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL
|
||||
* account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due
|
||||
* is taken out of the payout itself.
|
||||
*
|
||||
* <p>
|
||||
* Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a
|
||||
* withdrawal of the account balance pays out what is left. The deposit is not saved here - the
|
||||
* closing withdrawal saves it and posts the deduction's accounting along with its own. Then call
|
||||
* {@link #recordDirectPayout}.
|
||||
*
|
||||
* @param kind
|
||||
* CASH or GL, for the tracking record
|
||||
* @return what to record once the withdrawal exists, or null when the payout carried no interest
|
||||
*/
|
||||
public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) {
|
||||
if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) {
|
||||
return null;
|
||||
}
|
||||
// From memory: the maturity or pre-closure interest posted moments ago is not saved yet.
|
||||
BigDecimal posted = BigDecimal.ZERO;
|
||||
for (final SavingsAccountTransaction t : deposit.getTransactions()) {
|
||||
if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) {
|
||||
posted = posted.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(deposit, year);
|
||||
if (deductee == null) {
|
||||
return null;
|
||||
}
|
||||
final Due due = dueFor(deductee, year, date, interest);
|
||||
final SavingsAccountTransaction tds = due == null ? null
|
||||
: deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been
|
||||
* saved, and marks the deduction as already posted so the caller's own journal run does not post it a
|
||||
* second time - the withdrawal posted it.
|
||||
*/
|
||||
public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal,
|
||||
final Set<Long> alreadyPostedTransactionIds) {
|
||||
if (payout == null || withdrawal == null || withdrawal.getId() == null) {
|
||||
return;
|
||||
}
|
||||
final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(),
|
||||
payout.year(), payout.date(), payout.interest());
|
||||
if (payout.tds() != null && payout.tds().getId() != null) {
|
||||
recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(),
|
||||
payout.date(), payout.due());
|
||||
if (alreadyPostedTransactionIds != null) {
|
||||
alreadyPostedTransactionIds.add(payout.tds().getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */
|
||||
public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due,
|
||||
SavingsAccountTransaction tds) {}
|
||||
|
||||
/** TDS due now, and what it was worked out on. */
|
||||
public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {}
|
||||
|
||||
/** One Indian financial year. */
|
||||
public record Year(LocalDate start, LocalDate end, String label) {
|
||||
|
||||
static Year of(final LocalDate date) {
|
||||
final int startYear = TdsSql.fyStartYear(date);
|
||||
return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTimeDeposit(final SavingsAccount account) {
|
||||
final DepositAccountType type = account.depositAccountType();
|
||||
return type != null && (type.isFixedDeposit() || type.isRecurringDeposit());
|
||||
}
|
||||
|
||||
private Long deducteeOf(final SavingsAccount deposit, final Year year) {
|
||||
return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class,
|
||||
year.start(), deposit.getId());
|
||||
}
|
||||
|
||||
private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId,
|
||||
final String kind, final Year year, final LocalDate date, final BigDecimal interest) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id,
|
||||
kind, fy_label, received_on, interest_amount, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id
|
||||
""", Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest);
|
||||
}
|
||||
|
||||
private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId,
|
||||
final Long receiptId, final Year year, final LocalDate date, final Due due) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id,
|
||||
fy_label, deducted_on, amount, rate_percentage, interest_base, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(),
|
||||
due.liableInterest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The TDS due for the deductee now, or null when nothing is.
|
||||
*
|
||||
* @param extraInterest
|
||||
* interest received on {@code date} that is not on record yet
|
||||
*/
|
||||
private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) {
|
||||
final List<Map<String, Object>> cfgRows = jdbcTemplate.queryForList("""
|
||||
SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to
|
||||
FROM m_tds_fy_config WHERE fy_label = ?
|
||||
""", year.label());
|
||||
if (cfgRows.isEmpty()) {
|
||||
// Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there.
|
||||
log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee);
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> cfg = cfgRows.get(0);
|
||||
|
||||
final List<LocalDate> accepted = jdbcTemplate.queryForList("""
|
||||
SELECT accepted_on FROM m_tds_declaration
|
||||
WHERE client_id = ? AND fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", LocalDate.class, deductee, year.label());
|
||||
final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0);
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0) AS interest,
|
||||
COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ?
|
||||
""", shieldedFrom, deductee, year.label(), date);
|
||||
final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest);
|
||||
final BigDecimal liableInterest = ((BigDecimal) totals.get("liable"))
|
||||
.add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO);
|
||||
final BigDecimal deducted = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(d.amount), 0)
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.deductee_client_id = ? AND d.fy_label = ?
|
||||
""", BigDecimal.class, deductee, year.label());
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", deductee);
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end());
|
||||
final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general");
|
||||
if (yearInterest.compareTo(threshold) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan"));
|
||||
final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage");
|
||||
final int roundTo = ((Number) cfg.get("round_to")).intValue();
|
||||
final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo);
|
||||
final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted;
|
||||
final BigDecimal amount = liability.subtract(already);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(),
|
||||
yearInterest, liability, already);
|
||||
return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent));
|
||||
}
|
||||
|
||||
private BigDecimal postedFromDatabase(final Long accountId) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", BigDecimal.class, accountId, TdsSql.INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interest inside a maturity or closure payout: interest posted on the deposit that has not been
|
||||
* received already - through the interest sweep or an earlier payout. Capped at the payout.
|
||||
*
|
||||
* <p>
|
||||
* On a premature closure the months already swept to savings stand as they were; only the interest
|
||||
* for the days since is paid out now, and only that is counted.
|
||||
*/
|
||||
private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) {
|
||||
final BigDecimal swept = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(att.amount), 0)
|
||||
FROM m_account_transfer_transaction att
|
||||
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
|
||||
WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false
|
||||
""", BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue());
|
||||
final BigDecimal paidOut = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0)
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.source_account_id = ? AND r.kind <> 'INTEREST'
|
||||
""", BigDecimal.class, sourceAccountId);
|
||||
final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut);
|
||||
if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return unpaid.min(payout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tax component the deduction is booked through, created on first use and re-pointed whenever
|
||||
* the TDS payable account on the configuration screen changes.
|
||||
*/
|
||||
private TaxComponent tdsComponent(final BigDecimal ratePercent) {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured",
|
||||
"TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
final Long glId = gl.get(0);
|
||||
final List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class,
|
||||
TDS_COMPONENT_NAME);
|
||||
final Long id;
|
||||
boolean repointed = false;
|
||||
if (existing.isEmpty()) {
|
||||
id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date,
|
||||
createdby_id, created_date, lastmodifiedby_id, lastmodified_date)
|
||||
SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u
|
||||
RETURNING id
|
||||
""", Long.class, TDS_COMPONENT_NAME, ratePercent, glId);
|
||||
} else {
|
||||
id = existing.get(0);
|
||||
repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?"
|
||||
+ " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0;
|
||||
}
|
||||
final TaxComponent component = taxComponentRepository.findById(id).orElseThrow();
|
||||
// Written behind JPA's back, so a copy already in this session would still credit the old account.
|
||||
if (repointed) {
|
||||
entityManager.refresh(component);
|
||||
}
|
||||
return component;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -29,6 +29,8 @@ public interface TdsInterestReadPlatformService {
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
* @param clientId
|
||||
* one customer only, or null for everyone in the user's scope
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear, Long clientId);
|
||||
}
|
||||
|
||||
+122
-65
@@ -27,6 +27,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeclarationData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
@@ -35,8 +37,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
* Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is
|
||||
* worked out from the ledger rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -44,109 +46,150 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
* Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS
|
||||
* inside the year.
|
||||
*
|
||||
* <p>
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
* On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's
|
||||
* savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it
|
||||
* (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit
|
||||
* itself is never debited. Interest still sitting in a deposit is not counted until it is paid out.
|
||||
* Rows whose savings transaction has been reversed are left out: reversed money was never received.
|
||||
*
|
||||
* <p>
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
* On a savings account, "interest" is its own interest postings. The charge does not reach them, and
|
||||
* the caller keeps them out of the aggregate - but the branch still has to see them.
|
||||
*
|
||||
* <p>
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
* Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch
|
||||
* login therefore receives only its own customers. Each customer still arrives with every one of
|
||||
* their deposits, wherever held, because the threshold is tested on the bank as a whole.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
WITH acc AS (
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status,
|
||||
sa.client_id AS holder_id, %s AS deductee_id
|
||||
FROM m_savings_account sa
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
),
|
||||
lines AS (
|
||||
SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT r.source_account_id, 'C', r.received_on, r.interest_amount
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.received_on BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount
|
||||
FROM m_tds_deduction dd
|
||||
JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE dd.deducted_on BETWEEN ? AND ?
|
||||
)
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||
o.name AS branch_name,
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
a.id AS account_id,
|
||||
a.account_no AS account_no,
|
||||
a.activatedon_date AS opened_on,
|
||||
a.account_status AS account_status,
|
||||
a.holder_id AS holder_id,
|
||||
h.display_name AS holder_name,
|
||||
CASE a.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
l.kind AS line_kind,
|
||||
l.d AS txn_date,
|
||||
l.amt AS txn_amount
|
||||
FROM acc a
|
||||
JOIN m_client c ON c.id = a.deductee_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_client h ON h.id = a.holder_id
|
||||
LEFT JOIN lines l ON l.account_id = a.id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT))
|
||||
ORDER BY c.display_name, a.account_no, l.d
|
||||
""".formatted(TdsSql.deducteeOf("sa"));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear, final Long onlyClientId) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
final java.sql.Date f = java.sql.Date.valueOf(from);
|
||||
final java.sql.Date t = java.sql.Date.valueOf(to);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"),
|
||||
readDate(rs, "date_of_birth"), readString(rs, "branch_name")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId,
|
||||
id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"),
|
||||
readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
// Null where the outer join found nothing inside the year: the account is still listed, it
|
||||
// simply earned nothing in the window.
|
||||
final java.sql.Date date = rs.getDate("txn_date");
|
||||
if (date != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("txn_amount");
|
||||
final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo);
|
||||
if ("D".equals(rs.getString("line_kind"))) {
|
||||
account.deductions.add(line);
|
||||
} else {
|
||||
account.credits.add(line);
|
||||
}
|
||||
}
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
}, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId);
|
||||
|
||||
final Map<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus,
|
||||
a.openedOn, a.credits, a.deductions));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
d.form15Type, accounts));
|
||||
declarations.getOrDefault(d.clientId, List.of()), accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, List<TdsDeclarationData>> declarationsFor(final String fy) {
|
||||
final Map<Long, List<TdsDeclarationData>> out = new LinkedHashMap<>();
|
||||
jdbcTemplate.query("""
|
||||
SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income
|
||||
FROM m_tds_declaration
|
||||
WHERE fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", rs -> {
|
||||
final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"),
|
||||
rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"),
|
||||
false, null, null);
|
||||
out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d);
|
||||
}, fy);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
@@ -155,6 +198,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
}
|
||||
}
|
||||
|
||||
private static Long readLong(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final long v = rs.getLong(column);
|
||||
return rs.wasNull() ? null : v;
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
@@ -173,18 +225,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +242,21 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final Long holderId;
|
||||
private final String holderName;
|
||||
private final String accountStatus;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
private final List<TdsInterestCreditData> deductions = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus,
|
||||
LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.holderId = holderId;
|
||||
this.holderName = holderName;
|
||||
this.accountStatus = accountStatus;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user