Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
e02ab51ee1 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 | ||
|
|
24d3bf83bb | ||
|
|
af052dcb4f | ||
|
|
b12268e740 | ||
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
2155c94161 | ||
|
|
87f3e591ce | ||
|
|
1e903232f0 | ||
|
|
2c49d6f637 | ||
|
|
331c553164 | ||
|
|
3fb008a22a | ||
|
|
8b2fb052d4 | ||
|
|
cebba87864 | ||
|
|
ea39c8e82a | ||
|
|
32411c924b | ||
|
|
f696499102 | ||
|
|
7eabaf2542 | ||
|
|
4b9093007f | ||
|
|
9beae3df28 | ||
|
|
d2e13cc7b9 | ||
|
|
ecfe8aa76a | ||
|
|
a7cb09f15c | ||
|
|
26d4c241c7 | ||
|
|
dbc2737ada | ||
|
|
f457b9b8c7 | ||
|
|
23ee4f1063 | ||
|
|
bc38216ca3 | ||
|
|
fde2699957 | ||
|
|
dd4bb4a1c0 | ||
|
|
6046134b51 | ||
|
|
e4fad8b648 | ||
|
|
e206536a6b | ||
|
|
bf52c2cda1 | ||
|
|
934c11bf87 | ||
|
|
c1e6e036d0 | ||
|
|
4c0512f154 | ||
|
|
b779d04571 | ||
|
|
184d1b8cf8 | ||
|
|
521d61d6b7 | ||
|
|
af43944699 | ||
|
|
39fa100c70 | ||
|
|
1c6ac9d77f | ||
|
|
6bb512e1fe | ||
|
|
04ac233fbe | ||
|
|
9e0c04583d |
+20
-9
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
*/
|
*/
|
||||||
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activities an administrator may map from the Financial Activity Mappings screen.
|
||||||
|
*
|
||||||
|
* Held in one place because create and update both validate against it, and the two lists had already drifted -
|
||||||
|
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
|
||||||
|
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
|
||||||
|
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
|
||||||
|
*/
|
||||||
|
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
|
||||||
|
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
|
||||||
|
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
|
||||||
|
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
|
||||||
|
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
|
||||||
|
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
|
||||||
|
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
|
||||||
|
|
||||||
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
||||||
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||||
|
|
||||||
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||||
|
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
||||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||||
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
|
|
||||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
|
||||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
|
||||||
|
|
||||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||||
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
||||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
|
||||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||||
|
|||||||
+5
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
||||||
GL_ACCOUNT_DISABLED, //
|
GL_ACCOUNT_DISABLED, //
|
||||||
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
||||||
|
GL_ACCOUNT_IS_HEADER, //
|
||||||
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
||||||
|
|
||||||
public String errorMessage() {
|
public String errorMessage() {
|
||||||
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "Invalid debit or credit accounts are passed";
|
return "Invalid debit or credit accounts are passed";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "Target account does not allow manual adjustments";
|
return "Target account does not allow manual adjustments";
|
||||||
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||||
|
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
||||||
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||||
|
return "error.msg.glJournalEntry.invalid.account.is.header";
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -279,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
|
|
||||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
|
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
|
||||||
|
|
||||||
|
// Optional, not mandatory like the three below: it names an account for cash physically
|
||||||
|
// received at the counter for a share purchase, which every register in this bank now
|
||||||
|
// books against the account the money actually reached instead. Left absent, a product
|
||||||
|
// simply carries no such mapping, and any code path that still needs one (a genuine
|
||||||
|
// over-the-counter collection) fails with a named "GL not mapped" error rather than
|
||||||
|
// silently assuming the till.
|
||||||
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
|
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
|
||||||
.integerGreaterThanZero();
|
.integerGreaterThanZero();
|
||||||
|
|
||||||
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
|
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
|
||||||
|
|||||||
+2
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
|
|||||||
|
|
||||||
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
|
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
|
||||||
|
|
||||||
|
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
|
||||||
|
|
||||||
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
|
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
|
||||||
|
|
||||||
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
|
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
|
||||||
|
|||||||
+14
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
return accountMappingDetails;
|
return accountMappingDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
|
||||||
|
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
|
||||||
|
PortfolioProductType.CURRENT.getValue());
|
||||||
|
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
|
||||||
|
return setCashSavingsProductToGLAccountMaps(mappings);
|
||||||
|
}
|
||||||
|
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
|
||||||
|
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
|
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
|
||||||
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
|
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
|
||||||
|
|||||||
+7
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
|
// Interest Payable is editable on cash too, and optional. Interest posting routes
|
||||||
|
// through it on either basis, so a product can name one to carry interest declared
|
||||||
|
// but not yet credited; a product that names none still posts straight to Savings
|
||||||
|
// Control. Created-or-merged because a product configured before this row existed
|
||||||
|
// has no mapping to merge into.
|
||||||
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||||
|
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
// asset
|
// asset
|
||||||
|
|||||||
+35
-3
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
|
|||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||||
|
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
|
||||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
|||||||
case NONE:
|
case NONE:
|
||||||
break;
|
break;
|
||||||
case CASH_BASED:
|
case CASH_BASED:
|
||||||
// asset
|
// asset -- the one optional mapping here, so unlike the three below it needs its own
|
||||||
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
// handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
|
||||||
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
|
||||||
|
|
||||||
// income
|
// income
|
||||||
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
|
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
|
||||||
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
|||||||
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
|
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
|
||||||
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
|
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
|
||||||
|
* the accounting rule.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
|
||||||
|
* {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
|
||||||
|
* changed" for them -- a null value there cannot happen without failing validation first. Shares
|
||||||
|
* Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
|
||||||
|
* share products alike) only handles "present": it silently leaves an existing mapping row alone
|
||||||
|
* when the incoming value is null, rather than clearing it. Editing this one field out of an
|
||||||
|
* existing product would otherwise leave the product still carrying its old GL underneath, invisible
|
||||||
|
* on the edit form but still resolvable by any code path that looks it up directly.
|
||||||
|
*/
|
||||||
|
private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
|
||||||
|
final Map<String, Object> changes) {
|
||||||
|
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||||
|
element);
|
||||||
|
if (shareReferenceId != null) {
|
||||||
|
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
||||||
|
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
|
||||||
|
PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
|
||||||
|
if (existing != null) {
|
||||||
|
this.accountMappingRepository.delete(existing);
|
||||||
|
changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -451,7 +451,32 @@ public final class AccountingConstants {
|
|||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||||
|
/**
|
||||||
|
* The year's closed-out profit a dividend is declared against.
|
||||||
|
*
|
||||||
|
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||||
|
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||||
|
* distribution a second time.
|
||||||
|
*/
|
||||||
|
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||||
|
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||||
|
* calculation.
|
||||||
|
*/
|
||||||
|
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where the paise a dividend cannot divide evenly are collected.
|
||||||
|
*
|
||||||
|
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||||
|
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||||
|
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||||
|
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||||
|
*
|
||||||
|
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||||
|
*/
|
||||||
|
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+10
-20
@@ -137,7 +137,16 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
// 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) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,25 +160,6 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
|
||||||
if (!user.isCheckerSuperUser()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "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) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+111
@@ -756,6 +756,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deleteFund(final Long fundId) {
|
||||||
|
this.actionName = "DELETE";
|
||||||
|
this.entityName = "FUND";
|
||||||
|
this.entityId = fundId;
|
||||||
|
this.href = "/funds/" + fundId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createReport() {
|
public CommandWrapperBuilder createReport() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "REPORT";
|
this.entityName = "REPORT";
|
||||||
@@ -884,6 +892,70 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createPaymentType() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "PAYMENTTYPE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/paymenttypes/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "PAYMENTTYPE";
|
||||||
|
this.entityId = paymentTypeId;
|
||||||
|
this.href = "/paymenttypes/" + paymentTypeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) {
|
||||||
|
this.actionName = "DELETE";
|
||||||
|
this.entityName = "PAYMENTTYPE";
|
||||||
|
this.entityId = paymentTypeId;
|
||||||
|
this.href = "/paymenttypes/" + paymentTypeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createSponsorBank() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "SPONSORBANK";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/sponsorbanks";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "SPONSORBANK";
|
||||||
|
this.entityId = sponsorBankId;
|
||||||
|
this.href = "/sponsorbanks/" + sponsorBankId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateSponsorDdGlMapping() {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "SPONSORDDGLMAPPING";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/sponsordd/gl-mapping";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateInstitutionProfile() {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "INSTITUTIONPROFILE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/institutionprofile";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateBranchProfile(final Long officeId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "BRANCHPROFILE";
|
||||||
|
this.entityId = officeId;
|
||||||
|
this.href = "/branchprofile/" + officeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createLoanProduct() {
|
public CommandWrapperBuilder createLoanProduct() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "LOANPRODUCT";
|
this.entityName = "LOANPRODUCT";
|
||||||
@@ -3390,6 +3462,13 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateCurrency() {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "CURRENCY";
|
||||||
|
this.href = "/currencies";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
|
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "EXTERNALSERVICES";
|
this.entityName = "EXTERNALSERVICES";
|
||||||
@@ -4730,4 +4809,36 @@ public class CommandWrapperBuilder {
|
|||||||
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createSurvey() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/surveys";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "ACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
|
||||||
|
this.actionName = "DEACTIVATE";
|
||||||
|
this.entityName = "SURVEY";
|
||||||
|
this.entityId = surveyId;
|
||||||
|
this.href = "/surveys/" + surveyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-8
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandProcessingService processAndLogCommandService;
|
private final CommandProcessingService processAndLogCommandService;
|
||||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
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;
|
private final ConfigurationDomainService configurationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||||
AppUser maker = commandSource.getMaker();
|
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||||
if (maker == null) {
|
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
// 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
|
||||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
// 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.");
|
||||||
}
|
}
|
||||||
return commandSource;
|
return commandSource;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -72,6 +72,8 @@ public enum JobName {
|
|||||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||||
|
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||||
|
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||||
; //
|
; //
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|||||||
+12
-11
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
|
|||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.command.core.CommandPipeline;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
|
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
|
||||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
||||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
|
|
||||||
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
|
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
|
|||||||
public class CurrenciesApiResource {
|
public class CurrenciesApiResource {
|
||||||
|
|
||||||
private final OrganisationCurrencyReadPlatformService readPlatformService;
|
private final OrganisationCurrencyReadPlatformService readPlatformService;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
|
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
|
||||||
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
|
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
|
||||||
final var command = new CurrencyUpdateCommand();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency()
|
||||||
|
.withJson(toApiJsonSerializer.serialize(request)).build();
|
||||||
|
|
||||||
command.setPayload(request);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
|
||||||
final Supplier<CurrencyUpdateResponse> response = commandPipeline.send(command);
|
return toApiJsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.monetary.handler;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
||||||
|
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
|
||||||
|
import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "CURRENCY", action = "UPDATE")
|
||||||
|
public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final CurrencyWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies");
|
||||||
|
final List<String> currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes);
|
||||||
|
|
||||||
|
final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build();
|
||||||
|
|
||||||
|
final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.with(Map.of("currencies", response.getCurrencies())) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
|
|||||||
SAVING(2, "productType.saving"), //
|
SAVING(2, "productType.saving"), //
|
||||||
CLIENT(5, "productType.client"), //
|
CLIENT(5, "productType.client"), //
|
||||||
PROVISIONING(3, "productType.provisioning"), //
|
PROVISIONING(3, "productType.provisioning"), //
|
||||||
SHARES(4, "productType.shares"); //
|
SHARES(4, "productType.shares"), //
|
||||||
|
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
|
||||||
|
// product and a current product with the same id read each other's GL rows.
|
||||||
|
CURRENT(6, "productType.current"); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
@@ -52,17 +55,25 @@ public enum PortfolioProductType {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matched against each constant's own declared value above, not its declaration position --
|
||||||
|
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
|
||||||
|
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
|
||||||
|
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
|
||||||
|
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
|
||||||
|
// (entity_type_enum=6) reached this on the Journal Entries screen.
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case 1:
|
case 1:
|
||||||
return LOAN;
|
return LOAN;
|
||||||
case 2:
|
case 2:
|
||||||
return SAVING;
|
return SAVING;
|
||||||
case 3:
|
case 3:
|
||||||
return CLIENT;
|
|
||||||
case 4:
|
|
||||||
return PROVISIONING;
|
return PROVISIONING;
|
||||||
case 5:
|
case 4:
|
||||||
return SHARES;
|
return SHARES;
|
||||||
|
case 5:
|
||||||
|
return CLIENT;
|
||||||
|
case 6:
|
||||||
|
return CURRENT;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "disposition", length = 20)
|
@Column(name = "disposition", length = 20)
|
||||||
private String disposition;
|
private String disposition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other rules this same transaction matched, comma-separated.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
|
||||||
|
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
|
||||||
|
* stopping them clearing one while escalating another. The rules that did not win are kept here
|
||||||
|
* so the case file still shows everything that fired.
|
||||||
|
*/
|
||||||
|
@Column(name = "also_matched_rules", length = 500)
|
||||||
|
private String alsoMatchedRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
|
||||||
|
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
|
||||||
|
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
|
||||||
|
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
|
||||||
|
*/
|
||||||
|
@Column(name = "highest_severity")
|
||||||
|
private Integer highestSeverity;
|
||||||
|
|
||||||
protected AmlAlert() {}
|
protected AmlAlert() {}
|
||||||
|
|
||||||
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
|
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
|
||||||
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return alert;
|
return alert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAlsoMatchedRules() {
|
||||||
|
return this.alsoMatchedRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getHighestSeverity() {
|
||||||
|
return this.highestSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordHighestSeverity(final Integer severity) {
|
||||||
|
this.highestSeverity = severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
|
||||||
|
public void recordAlsoMatchedRules(final String ruleCodes) {
|
||||||
|
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
|
||||||
|
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this alert is being worked by somebody other than the given officer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
|
||||||
|
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
|
||||||
|
* would carry two reviews, and whichever was written last would be the one on file.
|
||||||
|
*/
|
||||||
|
public boolean isHeldByAnother(final AppUser officer) {
|
||||||
|
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
|
||||||
|
&& !this.reviewedBy.getId().equals(officer.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
|
||||||
|
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
|
||||||
|
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
|
||||||
|
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
|
||||||
|
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
|
||||||
|
*/
|
||||||
|
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
|
||||||
|
this.reviewedBy = principalOfficer;
|
||||||
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
this.reviewNotes = notes;
|
||||||
|
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
|
||||||
|
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
|
||||||
|
}
|
||||||
|
|
||||||
public void whitelist(AppUser reviewer, String notes) {
|
public void whitelist(AppUser reviewer, String notes) {
|
||||||
this.reviewedBy = reviewer;
|
this.reviewedBy = reviewer;
|
||||||
this.reviewedOn = LocalDateTime.now();
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
|||||||
+14
@@ -44,4 +44,18 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
|||||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||||
*/
|
*/
|
||||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer's still-open alerts inside the observation window, oldest first.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses
|
||||||
|
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
|
||||||
|
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the
|
||||||
|
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
|
||||||
|
* that a partial failure leaves the longest-standing ones dealt with.
|
||||||
|
*/
|
||||||
|
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
|
||||||
|
+ "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC")
|
||||||
|
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
|
||||||
}
|
}
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rule a transaction matched, and what the officer decided about that rule.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
|
||||||
|
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
|
||||||
|
* observation or sends the other five to an STR they do not belong in.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
|
||||||
|
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
|
||||||
|
* afterwards must not rewrite what the officer was looking at when they decided.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_aml_alert_rule")
|
||||||
|
public class AmlAlertRule {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "alert_id", nullable = false)
|
||||||
|
private AmlAlert alert;
|
||||||
|
|
||||||
|
@Column(name = "rule_code", nullable = false, length = 100)
|
||||||
|
private String ruleCode;
|
||||||
|
|
||||||
|
@Column(name = "risk_category")
|
||||||
|
private Integer riskCategory;
|
||||||
|
|
||||||
|
@Column(name = "is_governing", nullable = false)
|
||||||
|
private boolean governing;
|
||||||
|
|
||||||
|
@Column(name = "disposition", length = 20)
|
||||||
|
private String disposition;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "reviewed_by")
|
||||||
|
private AppUser reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_on")
|
||||||
|
private LocalDateTime reviewedOn;
|
||||||
|
|
||||||
|
@Column(name = "review_notes", length = 1000)
|
||||||
|
private String reviewNotes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the officer named this rule, as against it being closed to keep the case consistent.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
|
||||||
|
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
|
||||||
|
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
|
||||||
|
* the substance of the finding.
|
||||||
|
*/
|
||||||
|
@Column(name = "directly_decided", nullable = false)
|
||||||
|
private boolean directlyDecided;
|
||||||
|
|
||||||
|
protected AmlAlertRule() {}
|
||||||
|
|
||||||
|
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
|
||||||
|
final boolean governing) {
|
||||||
|
final AmlAlertRule finding = new AmlAlertRule();
|
||||||
|
finding.alert = alert;
|
||||||
|
finding.ruleCode = ruleCode;
|
||||||
|
finding.riskCategory = riskCategory;
|
||||||
|
finding.governing = governing;
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param directlyDecided
|
||||||
|
* true when the officer named this rule; false when it was closed alongside one they named
|
||||||
|
*/
|
||||||
|
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
|
||||||
|
final boolean directlyDecided) {
|
||||||
|
this.disposition = disposition.name();
|
||||||
|
this.reviewedBy = reviewer;
|
||||||
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
this.reviewNotes = notes;
|
||||||
|
this.directlyDecided = directlyDecided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDirectlyDecided() {
|
||||||
|
return this.directlyDecided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDecided() {
|
||||||
|
return this.disposition != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEscalated() {
|
||||||
|
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlAlert getAlert() {
|
||||||
|
return this.alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRuleCode() {
|
||||||
|
return this.ruleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRiskCategory() {
|
||||||
|
return this.riskCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGoverning() {
|
||||||
|
return this.governing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisposition() {
|
||||||
|
return this.disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUser getReviewedBy() {
|
||||||
|
return this.reviewedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getReviewedOn() {
|
||||||
|
return this.reviewedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReviewNotes() {
|
||||||
|
return this.reviewNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
|
||||||
|
|
||||||
|
List<AmlAlertRule> findByAlertId(Long alertId);
|
||||||
|
|
||||||
|
/** Every finding across a page of alerts, in one read rather than one query per alert. */
|
||||||
|
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
|
||||||
|
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
|
||||||
|
* fortieth review cost an officer the same effort as the first and changed as little.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
|
||||||
|
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
|
||||||
|
* about should come back sooner, not later.
|
||||||
|
*/
|
||||||
|
public enum AmlRecurringEscalationTier {
|
||||||
|
|
||||||
|
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
|
||||||
|
ROUTINE(0, 90),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
|
||||||
|
* the reviewer is asked to consider whether the rating still fits.
|
||||||
|
*/
|
||||||
|
ELEVATED(7, 30),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
|
||||||
|
* to be taken by someone senior.
|
||||||
|
*/
|
||||||
|
SENIOR(15, 14),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
|
||||||
|
* is a decision that has to be defensible on its own - so the review stands for a week only.
|
||||||
|
*/
|
||||||
|
MANDATORY_STR(30, 7);
|
||||||
|
|
||||||
|
private final int minimumAlertCount;
|
||||||
|
private final int reviewValidityDays;
|
||||||
|
|
||||||
|
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
|
||||||
|
this.minimumAlertCount = minimumAlertCount;
|
||||||
|
this.reviewValidityDays = reviewValidityDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tier a customer sitting on this many alerts falls into. */
|
||||||
|
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
|
||||||
|
AmlRecurringEscalationTier tier = ROUTINE;
|
||||||
|
for (final AmlRecurringEscalationTier candidate : values()) {
|
||||||
|
if (alertCount >= candidate.minimumAlertCount) {
|
||||||
|
tier = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMinimumAlertCount() {
|
||||||
|
return minimumAlertCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getReviewValidityDays() {
|
||||||
|
return reviewValidityDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the alert count may climb before a standing review is set aside and the customer returns early.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
|
||||||
|
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
|
||||||
|
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
|
||||||
|
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
|
||||||
|
*/
|
||||||
|
public int alertGrowthBeforeEarlyReturn() {
|
||||||
|
return switch (this) {
|
||||||
|
case ROUTINE -> 5;
|
||||||
|
case ELEVATED -> 4;
|
||||||
|
case SENIOR -> 3;
|
||||||
|
case MANDATORY_STR -> 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
|
||||||
|
public boolean expectsStr() {
|
||||||
|
return this == MANDATORY_STR;
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
|
|||||||
@Column(name = "reviewed_on", nullable = false)
|
@Column(name = "reviewed_on", nullable = false)
|
||||||
private LocalDateTime reviewedOn;
|
private LocalDateTime reviewedOn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
|
||||||
|
* behaviour of lapsing the moment one more alert lands.
|
||||||
|
*/
|
||||||
|
@Column(name = "valid_until")
|
||||||
|
private LocalDate validUntil;
|
||||||
|
|
||||||
|
@Column(name = "escalation_tier", length = 20)
|
||||||
|
private String escalationTier;
|
||||||
|
|
||||||
|
/** How many of the customer's open alerts this review actually disposed of. */
|
||||||
|
@Column(name = "alerts_actioned")
|
||||||
|
private Integer alertsActioned;
|
||||||
|
|
||||||
protected AmlRecurringReview() {}
|
protected AmlRecurringReview() {}
|
||||||
|
|
||||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||||
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
|
|||||||
review.notes = notes;
|
review.notes = notes;
|
||||||
review.reviewedBy = reviewedBy;
|
review.reviewedBy = reviewedBy;
|
||||||
review.reviewedOn = LocalDateTime.now();
|
review.reviewedOn = LocalDateTime.now();
|
||||||
|
|
||||||
|
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
|
||||||
|
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
|
||||||
|
// always being cleared.
|
||||||
|
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
|
||||||
|
review.escalationTier = tier.name();
|
||||||
|
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
|
||||||
|
review.alertsActioned = 0;
|
||||||
return review;
|
return review;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
|
||||||
|
public void recordAlertsActioned(final int count) {
|
||||||
|
this.alertsActioned = count;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
|
|||||||
public LocalDateTime getReviewedOn() {
|
public LocalDateTime getReviewedOn() {
|
||||||
return reviewedOn;
|
return reviewedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidUntil() {
|
||||||
|
return validUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlRecurringEscalationTier getEscalationTier() {
|
||||||
|
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAlertsActioned() {
|
||||||
|
return alertsActioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this review still holds the customer out of the review queue.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
|
||||||
|
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
|
||||||
|
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
|
||||||
|
* lapsing as soon as one more alert arrives.
|
||||||
|
*/
|
||||||
|
public boolean stillStands(final int currentAlertCount) {
|
||||||
|
if (validUntil == null) {
|
||||||
|
return currentAlertCount <= alertCountAtReview;
|
||||||
|
}
|
||||||
|
if (LocalDate.now().isAfter(validUntil)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One alert covered by an STR case.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
|
||||||
|
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
|
||||||
|
* Principal Officer has to be able to read in one place.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_aml_str_case_alert")
|
||||||
|
public class AmlStrCaseAlert {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "str_case_id", nullable = false)
|
||||||
|
private AmlStrCase strCase;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "alert_id", nullable = false)
|
||||||
|
private AmlAlert alert;
|
||||||
|
|
||||||
|
@Column(name = "added_on_utc", nullable = false)
|
||||||
|
private LocalDateTime addedOn;
|
||||||
|
|
||||||
|
protected AmlStrCaseAlert() {}
|
||||||
|
|
||||||
|
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
|
||||||
|
final AmlStrCaseAlert link = new AmlStrCaseAlert();
|
||||||
|
link.strCase = strCase;
|
||||||
|
link.alert = alert;
|
||||||
|
link.addedOn = LocalDateTime.now();
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlStrCase getStrCase() {
|
||||||
|
return strCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlAlert getAlert() {
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getAddedOn() {
|
||||||
|
return addedOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
|
||||||
|
|
||||||
|
/** The alerts one case covers, oldest first - the order the pattern happened in. */
|
||||||
|
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case already covering an alert, if any.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
|
||||||
|
* one, however it comes to be escalated again.
|
||||||
|
*/
|
||||||
|
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
|
||||||
|
|
||||||
|
long countByStrCase_Id(Long strCaseId);
|
||||||
|
}
|
||||||
+4
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
|
|||||||
|
|
||||||
long countByClient_Id(Long clientId);
|
long countByClient_Id(Long clientId);
|
||||||
|
|
||||||
|
/** The case already open for a transaction, so escalating it again does not open a second one. */
|
||||||
|
Optional<AmlStrCase> findByAlertId(Long alertId);
|
||||||
|
|
||||||
List<AmlStrCase> findByStatus(String status);
|
List<AmlStrCase> findByStatus(String status);
|
||||||
|
|
||||||
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
|
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
|
||||||
|
|||||||
+43
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||||
|
* A rule named for withdrawals must not fire on a deposit.
|
||||||
|
*/
|
||||||
|
@Column(name = "direction", length = 10)
|
||||||
|
private String direction;
|
||||||
|
|
||||||
|
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||||
|
@Column(name = "cash_only", nullable = false)
|
||||||
|
private boolean cashOnly;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||||
|
* many small transactions; without this floor a single large one satisfies the window total and
|
||||||
|
* reads as a pattern it is not.
|
||||||
|
*/
|
||||||
|
@Column(name = "min_transaction_count")
|
||||||
|
private Integer minTransactionCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||||
|
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||||
|
* about the transaction alone and so applies to everyone.
|
||||||
|
*/
|
||||||
|
@Column(name = "subject_scope", length = 30)
|
||||||
|
private String subjectScope;
|
||||||
|
|
||||||
|
public String getSubjectScope() {
|
||||||
|
return subjectScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDirection() {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCashOnly() {
|
||||||
|
return cashOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMinTransactionCount() {
|
||||||
|
return minTransactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
protected AmlTransactionRule() {}
|
protected AmlTransactionRule() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+7
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.domain;
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
|
|||||||
* by hand from the ledger.
|
* by hand from the ledger.
|
||||||
*/
|
*/
|
||||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
|
||||||
|
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
|
||||||
|
*/
|
||||||
|
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
|
||||||
}
|
}
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one customer's money actually did over a period, and what that says about their rating.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
|
||||||
|
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
|
||||||
|
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
|
||||||
|
* Neither is a pattern.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
|
||||||
|
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
|
||||||
|
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
|
||||||
|
* the system was looking at rather than a conclusion with no working.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_client_behaviour_window")
|
||||||
|
public class ClientBehaviourWindow {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "client_id", nullable = false)
|
||||||
|
private Long clientId;
|
||||||
|
|
||||||
|
@Column(name = "window_from", nullable = false)
|
||||||
|
private LocalDate windowFrom;
|
||||||
|
|
||||||
|
@Column(name = "window_to", nullable = false)
|
||||||
|
private LocalDate windowTo;
|
||||||
|
|
||||||
|
@Column(name = "txn_count")
|
||||||
|
private Integer txnCount;
|
||||||
|
|
||||||
|
@Column(name = "distinct_months")
|
||||||
|
private Integer distinctMonths;
|
||||||
|
|
||||||
|
@Column(name = "credit_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal creditTotal;
|
||||||
|
|
||||||
|
@Column(name = "debit_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal debitTotal;
|
||||||
|
|
||||||
|
@Column(name = "largest_txn", precision = 19, scale = 6)
|
||||||
|
private BigDecimal largestTxn;
|
||||||
|
|
||||||
|
@Column(name = "cash_txn_count")
|
||||||
|
private Integer cashTxnCount;
|
||||||
|
|
||||||
|
@Column(name = "cash_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal cashTotal;
|
||||||
|
|
||||||
|
@Column(name = "large_txn_count")
|
||||||
|
private Integer largeTxnCount;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualIncome;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualTurnover;
|
||||||
|
|
||||||
|
@Column(name = "income_ratio", precision = 19, scale = 6)
|
||||||
|
private BigDecimal incomeRatio;
|
||||||
|
|
||||||
|
@Column(name = "alert_count")
|
||||||
|
private Integer alertCount;
|
||||||
|
|
||||||
|
@Column(name = "str_case_count")
|
||||||
|
private Integer strCaseCount;
|
||||||
|
|
||||||
|
@Column(name = "risk_category_at_review")
|
||||||
|
private Integer riskCategoryAtReview;
|
||||||
|
|
||||||
|
/** UP, DOWN or HOLD. */
|
||||||
|
@Column(name = "suggested_direction", length = 10)
|
||||||
|
private String suggestedDirection;
|
||||||
|
|
||||||
|
@Column(name = "suggested_category")
|
||||||
|
private Integer suggestedCategory;
|
||||||
|
|
||||||
|
@Column(name = "suggestion_reason", length = 500)
|
||||||
|
private String suggestionReason;
|
||||||
|
|
||||||
|
@Column(name = "computed_on")
|
||||||
|
private LocalDateTime computedOn;
|
||||||
|
|
||||||
|
protected ClientBehaviourWindow() {}
|
||||||
|
|
||||||
|
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
|
||||||
|
final ClientBehaviourWindow window = new ClientBehaviourWindow();
|
||||||
|
window.clientId = clientId;
|
||||||
|
window.windowFrom = windowFrom;
|
||||||
|
window.windowTo = windowTo;
|
||||||
|
window.computedOn = LocalDateTime.now();
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordDistinctMonths(final Integer months) {
|
||||||
|
this.distinctMonths = months;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDistinctMonths() {
|
||||||
|
return this.distinctMonths;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
|
||||||
|
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
|
||||||
|
this.txnCount = txnCount;
|
||||||
|
this.creditTotal = creditTotal;
|
||||||
|
this.debitTotal = debitTotal;
|
||||||
|
this.largestTxn = largestTxn;
|
||||||
|
this.cashTxnCount = cashTxnCount;
|
||||||
|
this.cashTotal = cashTotal;
|
||||||
|
this.largeTxnCount = largeTxnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
|
||||||
|
this.alertCount = alertCount;
|
||||||
|
this.strCaseCount = strCaseCount;
|
||||||
|
this.riskCategoryAtReview = riskCategoryAtReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turnover measured against what the customer said to expect.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
|
||||||
|
* unknown as a benign zero would clear every customer the bank never asked.
|
||||||
|
*/
|
||||||
|
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
|
||||||
|
this.declaredAnnualIncome = income;
|
||||||
|
this.declaredAnnualTurnover = turnover;
|
||||||
|
final BigDecimal basis = income != null && income.signum() > 0 ? income
|
||||||
|
: (turnover != null && turnover.signum() > 0 ? turnover : null);
|
||||||
|
this.incomeRatio = basis == null || periodTurnover == null ? null
|
||||||
|
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuggestion(final String direction, final Integer category, final String reason) {
|
||||||
|
this.suggestedDirection = direction;
|
||||||
|
this.suggestedCategory = category;
|
||||||
|
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getClientId() {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getWindowFrom() {
|
||||||
|
return this.windowFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getWindowTo() {
|
||||||
|
return this.windowTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTxnCount() {
|
||||||
|
return this.txnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getCashTotal() {
|
||||||
|
return this.cashTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLargeTxnCount() {
|
||||||
|
return this.largeTxnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAlertCount() {
|
||||||
|
return this.alertCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getStrCaseCount() {
|
||||||
|
return this.strCaseCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRiskCategoryAtReview() {
|
||||||
|
return this.riskCategoryAtReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSuggestedDirection() {
|
||||||
|
return this.suggestedDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSuggestedCategory() {
|
||||||
|
return this.suggestedCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
|
||||||
|
|
||||||
|
List<ClientBehaviourWindow> findByClientId(Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the windows computed for a given day before the job writes that day's.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
|
||||||
|
* set of windows rather than a customer appearing several times on the review screen with slightly different
|
||||||
|
* figures.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
|
||||||
|
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
|
||||||
|
}
|
||||||
+58
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "form15_fy", length = 10)
|
@Column(name = "form15_fy", length = 10)
|
||||||
private String form15Fy;
|
private String form15Fy;
|
||||||
|
|
||||||
|
// ── Risk profile ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
|
||||||
|
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
|
||||||
|
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_pep", nullable = false)
|
||||||
|
private boolean isPep;
|
||||||
|
|
||||||
|
@Column(name = "sanctions_match", nullable = false)
|
||||||
|
private boolean sanctionsMatch;
|
||||||
|
|
||||||
|
@Column(name = "adverse_media_match", nullable = false)
|
||||||
|
private boolean adverseMediaMatch;
|
||||||
|
|
||||||
|
/** What the customer told the bank to expect, which their actual turnover is measured against. */
|
||||||
|
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualIncome;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualTurnover;
|
||||||
|
|
||||||
// ── CKYCRR ───────────────────────────────────────────────────────────────
|
// ── CKYCRR ───────────────────────────────────────────────────────────────
|
||||||
/** Central KYC Records Registry number issued by CERSAI */
|
/** Central KYC Records Registry number issued by CERSAI */
|
||||||
@Column(name = "ckyc_number", length = 14)
|
@Column(name = "ckyc_number", length = 14)
|
||||||
@@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
v -> this.aadhaarReceiptRemarks = v);
|
v -> this.aadhaarReceiptRemarks = v);
|
||||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||||
v -> this.aadhaarReceiptTakenOn = v);
|
v -> this.aadhaarReceiptTakenOn = v);
|
||||||
|
// Risk profile. These are determinations about the customer rather than about a transaction, and the
|
||||||
|
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
|
||||||
|
// period's activity looks.
|
||||||
|
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
|
||||||
|
v -> this.sanctionsMatch = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
|
||||||
|
v -> this.adverseMediaMatch = v != null ? v : false);
|
||||||
|
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
|
||||||
|
v -> this.declaredAnnualIncome = v);
|
||||||
|
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
|
||||||
|
v -> this.declaredAnnualTurnover = v);
|
||||||
|
|
||||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||||
@@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
recalculateExpiryForRiskCategory();
|
recalculateExpiryForRiskCategory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPep() {
|
||||||
|
return this.isPep;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSanctionsMatch() {
|
||||||
|
return this.sanctionsMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAdverseMediaMatch() {
|
||||||
|
return this.adverseMediaMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getDeclaredAnnualIncome() {
|
||||||
|
return this.declaredAnnualIncome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getDeclaredAnnualTurnover() {
|
||||||
|
return this.declaredAnnualTurnover;
|
||||||
|
}
|
||||||
|
|
||||||
public String getPanNumber() {
|
public String getPanNumber() {
|
||||||
return panNumber;
|
return panNumber;
|
||||||
}
|
}
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
|
||||||
|
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
|
||||||
|
* three times a month needs different figures from one lending to traders.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_risk_policy_parameter")
|
||||||
|
public class RiskPolicyParameter {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "parameter_key", nullable = false, length = 80)
|
||||||
|
private String parameterKey;
|
||||||
|
|
||||||
|
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
|
||||||
|
private BigDecimal parameterValue;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "last_modified_by")
|
||||||
|
private Long lastModifiedBy;
|
||||||
|
|
||||||
|
@Column(name = "last_modified_on")
|
||||||
|
private LocalDateTime lastModifiedOn;
|
||||||
|
|
||||||
|
protected RiskPolicyParameter() {}
|
||||||
|
|
||||||
|
public String getParameterKey() {
|
||||||
|
return this.parameterKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getParameterValue() {
|
||||||
|
return this.parameterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return this.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
|
||||||
+19
-29
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.command.core.CommandPipeline;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class PaymentTypeApiResource {
|
public class PaymentTypeApiResource {
|
||||||
|
|
||||||
private final PaymentTypeReadService readPlatformService;
|
private final PaymentTypeReadService readPlatformService;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
|
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
|
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
|
||||||
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
|
public String createPaymentType(final PaymentTypeCreateRequest request) {
|
||||||
final var command = new PaymentTypeCreateCommand();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType()
|
||||||
|
.withJson(jsonSerializer.serialize(request)).build();
|
||||||
|
|
||||||
command.setPayload(request);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
|
||||||
final Supplier<PaymentTypeCreateResponse> response = commandPipeline.send(command);
|
return jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
|
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
|
||||||
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
|
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
|
||||||
final PaymentTypeUpdateRequest request) {
|
|
||||||
|
|
||||||
request.setId(paymentTypeId);
|
request.setId(paymentTypeId);
|
||||||
|
|
||||||
final var command = new PaymentTypeUpdateCommand();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId)
|
||||||
|
.withJson(jsonSerializer.serialize(request)).build();
|
||||||
|
|
||||||
command.setPayload(request);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
|
||||||
final Supplier<PaymentTypeUpdateResponse> response = commandPipeline.send(command);
|
return jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
|
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
|
||||||
public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
|
public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
|
||||||
|
|
||||||
final var command = new PaymentTypeDeleteCommand();
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build();
|
||||||
|
|
||||||
command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build());
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
|
||||||
final Supplier<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
|
return jsonSerializer.serialize(result);
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-17
@@ -18,33 +18,50 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
|
|
||||||
return paymentTypeWriteService.createPaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
|
||||||
|
|
||||||
|
final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder()
|
||||||
|
.name(command.stringValueOfParameterNamed("name"))
|
||||||
|
.description(command.stringValueOfParameterNamed("description"))
|
||||||
|
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
|
||||||
|
.position(command.longValueOfParameterNamed("position"))
|
||||||
|
.codeName(command.stringValueOfParameterNamed("codeName"))
|
||||||
|
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(response.getResourceId()) //
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-17
@@ -18,33 +18,41 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
|
|
||||||
return paymentTypeWriteService.deletePaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build();
|
||||||
|
|
||||||
|
final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(response.getResourceId()) //
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-17
@@ -18,33 +18,51 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
||||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
|
||||||
@RequiredArgsConstructor
|
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
|
||||||
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
|
|
||||||
|
|
||||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||||
|
|
||||||
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
|
@Autowired
|
||||||
@Override
|
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||||
@Transactional
|
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||||
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
|
|
||||||
return paymentTypeWriteService.updatePaymentType(command.getPayload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return CommandHandler.super.fallback(command, t);
|
|
||||||
|
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
|
||||||
|
|
||||||
|
final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder()
|
||||||
|
.id(command.entityId())
|
||||||
|
.name(command.stringValueOfParameterNamed("name"))
|
||||||
|
.description(command.stringValueOfParameterNamed("description"))
|
||||||
|
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
|
||||||
|
.position(command.longValueOfParameterNamed("position"))
|
||||||
|
.codeName(command.stringValueOfParameterNamed("codeName"))
|
||||||
|
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(response.getResourceId()) //
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
|
|||||||
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
||||||
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
||||||
public static final String tenureInMonthsParamName = "tenureInMonths";
|
public static final String tenureInMonthsParamName = "tenureInMonths";
|
||||||
|
public static final String tenureInDaysParamName = "tenureInDays";
|
||||||
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
||||||
|
|
||||||
// Preclosure parameters
|
// Preclosure parameters
|
||||||
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
|
|||||||
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
|
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
|
||||||
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
|
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
|
||||||
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
|
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
|
||||||
|
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
|
||||||
|
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
|
||||||
|
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
|
||||||
|
|
||||||
public static final String depositAmountParamName = "depositAmount";
|
public static final String depositAmountParamName = "depositAmount";
|
||||||
public static final String depositMinAmountParamName = "minDepositAmount";
|
public static final String depositMinAmountParamName = "minDepositAmount";
|
||||||
@@ -247,11 +251,13 @@ public final class DepositsApiConstants {
|
|||||||
|
|
||||||
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||||
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
|
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
|
||||||
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||||
|
renewalGracePeriodDaysParamName));
|
||||||
|
|
||||||
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||||
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
|
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
|
||||||
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||||
|
renewalGracePeriodDaysParamName));
|
||||||
|
|
||||||
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
|
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
|
||||||
@@ -511,6 +517,7 @@ public final class DepositsApiConstants {
|
|||||||
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
||||||
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
||||||
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
||||||
|
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
|
||||||
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
||||||
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
||||||
// Exact calendar-based calculation params
|
// Exact calendar-based calculation params
|
||||||
|
|||||||
+9
@@ -286,6 +286,15 @@ public class SavingsApiConstants {
|
|||||||
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
|
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
|
||||||
public static final String validForPeriodParamName = "validForPeriod";
|
public static final String validForPeriodParamName = "validForPeriod";
|
||||||
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
|
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
|
||||||
|
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
|
||||||
|
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
|
||||||
|
public static final String odMinLimitParamName = "odMinLimit";
|
||||||
|
public static final String odSecurityTypeParamName = "odSecurityType";
|
||||||
|
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
|
||||||
|
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
|
||||||
|
public static final String odExcessAllowedParamName = "odExcessAllowed";
|
||||||
|
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
|
||||||
|
public static final String odPenalRateParamName = "odPenalRate";
|
||||||
|
|
||||||
// Status of Account fields
|
// Status of Account fields
|
||||||
public static final String accountStatusParamName = "accountStatus";
|
public static final String accountStatusParamName = "accountStatus";
|
||||||
|
|||||||
+10
@@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable {
|
|||||||
@Getter @Setter private String avgBalanceCalcPeriod;
|
@Getter @Setter private String avgBalanceCalcPeriod;
|
||||||
@Getter @Setter private String validForPeriod;
|
@Getter @Setter private String validForPeriod;
|
||||||
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
|
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
|
||||||
|
@Getter @Setter private BigDecimal odMinLimit;
|
||||||
|
@Getter @Setter private String odSecurityType;
|
||||||
|
@Getter @Setter private Boolean odExcessAllowed;
|
||||||
|
@Getter @Setter private BigDecimal odExcessLimitPercent;
|
||||||
|
@Getter @Setter private BigDecimal odPenalRate;
|
||||||
|
|
||||||
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
|
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
|
||||||
@Getter @Setter private Map<String, Object> accountFacilityConfig;
|
@Getter @Setter private Map<String, Object> accountFacilityConfig;
|
||||||
@@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable {
|
|||||||
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
|
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
|
||||||
target.setValidForPeriod(source.getValidForPeriod());
|
target.setValidForPeriod(source.getValidForPeriod());
|
||||||
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
|
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
|
||||||
|
target.setOdMinLimit(source.getOdMinLimit());
|
||||||
|
target.setOdSecurityType(source.getOdSecurityType());
|
||||||
|
target.setOdExcessAllowed(source.getOdExcessAllowed());
|
||||||
|
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
|
||||||
|
target.setOdPenalRate(source.getOdPenalRate());
|
||||||
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
|
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
|
||||||
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
|
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
|
||||||
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
|
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
|
||||||
|
|||||||
+2
-1
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
|
|||||||
@Column(name = "status_enum", nullable = false)
|
@Column(name = "status_enum", nullable = false)
|
||||||
private Integer statusEnum = 100;
|
private Integer statusEnum = 100;
|
||||||
|
|
||||||
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
|
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
|
||||||
|
// 600=BlockDebit
|
||||||
@Column(name = "sub_status_enum", nullable = false)
|
@Column(name = "sub_status_enum", nullable = false)
|
||||||
private Integer subStatusEnum = 0;
|
private Integer subStatusEnum = 0;
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
|
|||||||
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
||||||
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
|
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
|
||||||
|
|
||||||
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
|
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
|
||||||
|
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
|
||||||
|
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
|
||||||
|
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
|
||||||
|
// active) are the only statuses a second application on the same product should actually collide
|
||||||
|
// with.
|
||||||
|
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
|
||||||
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
|
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
|||||||
@JoinColumn(name = "office_id", nullable = false)
|
@JoinColumn(name = "office_id", nullable = false)
|
||||||
private Office office;
|
private Office office;
|
||||||
|
|
||||||
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
|
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
|
||||||
@Column(name = "transaction_type_enum", nullable = false)
|
@Column(name = "transaction_type_enum", nullable = false)
|
||||||
private Integer transactionTypeEnum;
|
private Integer transactionTypeEnum;
|
||||||
|
|
||||||
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
|||||||
return txn;
|
return txn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
|
||||||
|
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
|
||||||
|
BigDecimal amount, LocalDate transactionDate) {
|
||||||
|
CurrentAccountTransaction txn = new CurrentAccountTransaction();
|
||||||
|
txn.currentAccount = account;
|
||||||
|
txn.office = office;
|
||||||
|
txn.transactionTypeEnum = 19;
|
||||||
|
txn.amount = amount;
|
||||||
|
txn.transactionDate = transactionDate;
|
||||||
|
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
|
||||||
|
txn.submittedOnDate = transactionDate;
|
||||||
|
return txn;
|
||||||
|
}
|
||||||
|
|
||||||
// transactionTypeEnum = 7 (Reversal / Undo)
|
// transactionTypeEnum = 7 (Reversal / Undo)
|
||||||
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
|
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
|
||||||
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
|
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
|
||||||
|
|||||||
+6
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
|
|||||||
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
|
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
|
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
|
||||||
|
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
|
||||||
|
*/
|
||||||
|
boolean escheat(Long accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-16
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
|
|||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
|
||||||
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
|
|||||||
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
|
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
|
||||||
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
|
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
|
||||||
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
|
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
|
||||||
SavingsApiConstants.glAccountIdParamName));
|
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
|
||||||
|
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
|
||||||
|
|
||||||
|
/** How long an OD sanction on this scheme runs before it has to be renewed. */
|
||||||
|
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
|
||||||
|
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
|
||||||
|
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
|
||||||
|
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
|
||||||
|
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
|
||||||
|
/** Excess drawing is a small tolerance over the limit, never a second limit. */
|
||||||
|
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
|
||||||
|
|
||||||
public void validateForCreate(final String json) {
|
public void validateForCreate(final String json) {
|
||||||
if (StringUtils.isBlank(json)) {
|
if (StringUtils.isBlank(json)) {
|
||||||
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
|
|||||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
|
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
validateOverdraftParams(baseDataValidator, element);
|
validateOverdraftParams(baseDataValidator, element, null);
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||||
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
|
|||||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
|
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null != isDormancyActive && isDormancyActive) {
|
if (null != isDormancyActive && isDormancyActive) {
|
||||||
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateOverdraftParams(baseDataValidator, element);
|
validateOverdraftParams(baseDataValidator, element, product);
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||||
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
/**
|
||||||
|
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
|
||||||
|
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
|
||||||
|
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
|
||||||
|
* as RBI requires for advances, or at most quarterly.
|
||||||
|
*
|
||||||
|
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
|
||||||
|
* checked against the scheme it will produce; on create it is null.
|
||||||
|
*/
|
||||||
|
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
|
||||||
|
final CurrentProduct product) {
|
||||||
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
|
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
|
||||||
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
|
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
|
||||||
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
|
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
|
||||||
}
|
}
|
||||||
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
|
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
|
||||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
|
||||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
|
: product != null && product.isAllowOverdraft();
|
||||||
|
|
||||||
|
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
|
||||||
|
product == null ? null : product.getOverdraftLimit());
|
||||||
|
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
|
||||||
|
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
|
||||||
|
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
|
||||||
|
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
|
||||||
|
product == null ? null : product.getMinOverdraftForInterestCalculation());
|
||||||
|
|
||||||
|
if (!allowOverdraft) {
|
||||||
|
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||||
|
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
|
||||||
|
.zeroOrPositiveAmount();
|
||||||
|
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||||
|
.zeroOrPositiveAmount();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
|
|
||||||
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
|
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
|
||||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
|
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||||
|
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
|
||||||
|
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
|
||||||
|
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
|
||||||
}
|
}
|
||||||
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
|
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
|
||||||
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
|
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
|
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||||
|
.zeroOrPositiveAmount();
|
||||||
|
|
||||||
|
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
|
||||||
|
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
|
||||||
|
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
|
||||||
|
|
||||||
|
final String securityType = stringOrStored(element, odSecurityTypeParamName,
|
||||||
|
product == null ? null : product.getOdSecurityType());
|
||||||
|
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
|
||||||
|
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
|
||||||
|
|
||||||
|
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
|
||||||
|
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
|
||||||
|
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
|
||||||
|
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
|
||||||
|
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
|
||||||
|
: product != null && product.isOdExcessAllowed();
|
||||||
|
if (excessAllowed) {
|
||||||
|
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
|
||||||
|
product == null ? null : product.getOdExcessLimitPercent());
|
||||||
|
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
|
||||||
|
.notGreaterThanMax(MAX_EXCESS_PERCENT);
|
||||||
|
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
|
||||||
|
product == null ? null : product.getOdPenalRate());
|
||||||
|
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
|
||||||
|
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
|
||||||
|
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
|
||||||
|
: product == null ? null : product.getInterestPostingPeriodType();
|
||||||
|
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
|
||||||
|
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
|
||||||
|
SavingsPostingInterestPeriodType.QUATERLY.getValue());
|
||||||
|
|
||||||
|
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
|
||||||
|
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
|
||||||
|
: product == null ? null : product.getInterestCalculationDaysInYearType();
|
||||||
|
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
|
||||||
|
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
|
||||||
|
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
|
||||||
|
return this.fromApiJsonHelper.parameterExists(paramName, element)
|
||||||
|
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
|
||||||
|
: stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
|
||||||
|
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
|
||||||
|
: stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
||||||
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
|
|||||||
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
|
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
|
||||||
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
|
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
|
||||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
||||||
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
||||||
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
|
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
|||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
import com.google.gson.JsonArray;
|
||||||
import jakarta.persistence.CascadeType;
|
import jakarta.persistence.CascadeType;
|
||||||
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "od_account_closed_zero_balance_in_day_closing")
|
@Column(name = "od_account_closed_zero_balance_in_day_closing")
|
||||||
private boolean odAccountClosedZeroBalanceInDayClosing = false;
|
private boolean odAccountClosedZeroBalanceInDayClosing = false;
|
||||||
|
|
||||||
|
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
|
||||||
|
@Column(name = "od_min_limit", scale = 6, precision = 19)
|
||||||
|
private BigDecimal odMinLimit;
|
||||||
|
|
||||||
|
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
|
||||||
|
@Column(name = "od_security_type", length = 20)
|
||||||
|
private String odSecurityType;
|
||||||
|
|
||||||
|
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
|
||||||
|
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
|
||||||
|
@Column(name = "od_excess_allowed")
|
||||||
|
private boolean odExcessAllowed = false;
|
||||||
|
|
||||||
|
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
|
||||||
|
private BigDecimal odExcessLimitPercent;
|
||||||
|
|
||||||
|
@Column(name = "od_penal_rate", scale = 6, precision = 19)
|
||||||
|
private BigDecimal odPenalRate;
|
||||||
|
|
||||||
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
|
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
|
||||||
private CurrentProductFacilityConfig facilityConfig;
|
private CurrentProductFacilityConfig facilityConfig;
|
||||||
|
|
||||||
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
|||||||
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
|
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
|
||||||
this.odAccountClosedZeroBalanceInDayClosing = newValue;
|
this.odAccountClosedZeroBalanceInDayClosing = newValue;
|
||||||
}
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
|
||||||
|
actualChanges.put(odMinLimitParamName, newValue);
|
||||||
|
this.odMinLimit = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
|
||||||
|
actualChanges.put(odSecurityTypeParamName, newValue);
|
||||||
|
this.odSecurityType = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||||
|
actualChanges.put(odExcessAllowedParamName, newValue);
|
||||||
|
this.odExcessAllowed = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
|
||||||
|
actualChanges.put(odExcessLimitPercentParamName, newValue);
|
||||||
|
this.odExcessLimitPercent = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
|
||||||
|
actualChanges.put(odPenalRateParamName, newValue);
|
||||||
|
this.odPenalRate = newValue;
|
||||||
|
}
|
||||||
|
if (!this.odExcessAllowed) {
|
||||||
|
this.odExcessLimitPercent = null;
|
||||||
|
this.odPenalRate = null;
|
||||||
|
}
|
||||||
// Clear OD-specific fields when overdraft is disabled
|
// Clear OD-specific fields when overdraft is disabled
|
||||||
if (!this.allowOverdraft) {
|
if (!this.allowOverdraft) {
|
||||||
this.avgBalanceCalcPeriod = null;
|
this.avgBalanceCalcPeriod = null;
|
||||||
this.validForPeriod = null;
|
this.validForPeriod = null;
|
||||||
this.odAccountClosedZeroBalanceInDayClosing = false;
|
this.odAccountClosedZeroBalanceInDayClosing = false;
|
||||||
|
this.odMinLimit = null;
|
||||||
|
this.odSecurityType = null;
|
||||||
|
this.odExcessAllowed = false;
|
||||||
|
this.odExcessLimitPercent = null;
|
||||||
|
this.odPenalRate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return actualChanges;
|
return actualChanges;
|
||||||
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
|||||||
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
|
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
|
||||||
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
|
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
|
||||||
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
|
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
|
||||||
|
public BigDecimal getOdMinLimit() { return odMinLimit; }
|
||||||
|
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
|
||||||
|
public String getOdSecurityType() { return odSecurityType; }
|
||||||
|
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
|
||||||
|
public boolean isOdExcessAllowed() { return odExcessAllowed; }
|
||||||
|
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
|
||||||
|
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
|
||||||
|
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
|
||||||
|
public BigDecimal getOdPenalRate() { return odPenalRate; }
|
||||||
|
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
|||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
|
||||||
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
|
|||||||
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
|
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
|
||||||
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
|
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
|
||||||
}
|
}
|
||||||
|
if (command.parameterExists(odMinLimitParamName)) {
|
||||||
|
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
|
||||||
|
}
|
||||||
|
if (command.parameterExists(odSecurityTypeParamName)) {
|
||||||
|
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
|
||||||
|
}
|
||||||
|
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
|
||||||
|
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||||
|
product.setOdExcessAllowed(excessAllowed);
|
||||||
|
if (excessAllowed) {
|
||||||
|
if (command.parameterExists(odExcessLimitPercentParamName)) {
|
||||||
|
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
|
||||||
|
}
|
||||||
|
if (command.parameterExists(odPenalRateParamName)) {
|
||||||
|
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return product;
|
return product;
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.currentproduct.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
|
||||||
|
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
|
||||||
|
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
|
||||||
|
* the two can never meet.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
|
||||||
|
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CurrentProductGlMappingWriter {
|
||||||
|
|
||||||
|
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
|
||||||
|
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
|
||||||
|
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
|
||||||
|
*
|
||||||
|
* @param accountingRule
|
||||||
|
* the product's accounting rule after this request (1 = NONE)
|
||||||
|
*/
|
||||||
|
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
|
||||||
|
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||||
|
if (accountingRule == null || accountingRule == 1) {
|
||||||
|
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
final JsonElement parsed = command.parsedJson();
|
||||||
|
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||||
|
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
|
||||||
|
final String param = entry.getKey();
|
||||||
|
if (!json.has(param)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
|
||||||
|
final Long existing = jdbcTemplate.queryForList(
|
||||||
|
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||||
|
+ " AND payment_type IS NULL AND charge_id IS NULL",
|
||||||
|
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
|
||||||
|
if (java.util.Objects.equals(existing, glAccountId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||||
|
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
|
||||||
|
if (glAccountId != null) {
|
||||||
|
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
|
||||||
|
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
|
||||||
|
}
|
||||||
|
changes.put(param, glAccountId);
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes every mapping of a deleted product. */
|
||||||
|
public void deleteAll(final Long productId) {
|
||||||
|
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
|||||||
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
|
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
|
||||||
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
|
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
|
||||||
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
|
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
|
||||||
|
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
|
||||||
|
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
|
||||||
|
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
|
||||||
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
|
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
|
||||||
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
|
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
|
||||||
|
|
||||||
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
|||||||
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
|
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
|
||||||
productData.setValidForPeriod(rs.getString("validForPeriod"));
|
productData.setValidForPeriod(rs.getString("validForPeriod"));
|
||||||
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
|
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
|
||||||
|
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
|
||||||
|
productData.setOdSecurityType(rs.getString("odSecurityType"));
|
||||||
|
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
|
||||||
|
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
|
||||||
|
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
|
||||||
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
|
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
|
||||||
|
|
||||||
return productData;
|
return productData;
|
||||||
|
|||||||
+6
-6
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
|||||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandSourceRepository commandSourceRepository;
|
private final CommandSourceRepository commandSourceRepository;
|
||||||
|
private final CurrentProductGlMappingWriter glMappingWriter;
|
||||||
|
|
||||||
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
|
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
|
||||||
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
|
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
|
||||||
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
|||||||
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
|
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
|
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(product.getId()) //
|
.withEntityId(product.getId()) //
|
||||||
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// accounting related changes
|
// accounting related changes
|
||||||
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
|
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
|
||||||
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
|
product.getAccountingType());
|
||||||
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
|
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
|
||||||
changes.putAll(accountingMappingChanges);
|
changes.putAll(accountingMappingChanges);
|
||||||
|
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
|||||||
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
|
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
|
||||||
|
|
||||||
this.currentProductRepository.delete(product);
|
this.currentProductRepository.delete(product);
|
||||||
|
this.glMappingWriter.deleteAll(productId);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(product.getId()) //
|
.withEntityId(product.getId()) //
|
||||||
|
|||||||
+3
-2
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
|
|||||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
|
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
|
||||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
|
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
|
||||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
|
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
|
||||||
|
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
|
||||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
|
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
|
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
|
||||||
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
|
|||||||
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
||||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||||
FromJsonHelper fromApiJsonHelper,
|
FromJsonHelper fromApiJsonHelper,
|
||||||
CommandSourceRepository commandSourceRepository) {
|
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
|
||||||
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
|
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
|
||||||
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
|
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
|
||||||
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
|
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
|
||||||
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
|
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
|
||||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
|
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.balancesheet.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An "as on date" balance sheet built directly off the live chart of accounts and journal entries,
|
||||||
|
* rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise
|
||||||
|
* layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit
|
||||||
|
* Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header)
|
||||||
|
* children indented underneath it and a subtotal on the block itself. A Detail account with no
|
||||||
|
* Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone)
|
||||||
|
* still needs to appear somewhere, so it is listed as its own root-level line rather than silently
|
||||||
|
* dropped.
|
||||||
|
*
|
||||||
|
* A Header account never itself carries a journal entry (see the header/detail discussion this
|
||||||
|
* feature grew out of), so its balance is always the recursive sum of its children - never read off
|
||||||
|
* acc_gl_journal_entry directly.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/balancesheet")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class BalanceSheetApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// classification_enum values (see acc_gl_account / GLAccountType).
|
||||||
|
private static final int ASSET = 1;
|
||||||
|
private static final int LIABILITY = 2;
|
||||||
|
private static final int EQUITY = 3;
|
||||||
|
private static final int INCOME = 4;
|
||||||
|
private static final int EXPENSE = 5;
|
||||||
|
// account_usage values (see GLAccountUsage).
|
||||||
|
private static final int DETAIL = 1;
|
||||||
|
private static final int HEADER = 2;
|
||||||
|
|
||||||
|
private static final class Node {
|
||||||
|
|
||||||
|
Long id;
|
||||||
|
String name;
|
||||||
|
String glCode;
|
||||||
|
int usage;
|
||||||
|
int classification;
|
||||||
|
Long parentId;
|
||||||
|
BigDecimal ownBalance = BigDecimal.ZERO;
|
||||||
|
final List<Node> children = new ArrayList<>();
|
||||||
|
|
||||||
|
BigDecimal balance() {
|
||||||
|
if (usage == DETAIL) {
|
||||||
|
return ownBalance;
|
||||||
|
}
|
||||||
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
|
for (final Node child : children) {
|
||||||
|
sum = sum.add(child.balance());
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> toJson() {
|
||||||
|
final Map<String, Object> json = new LinkedHashMap<>();
|
||||||
|
json.put("id", id);
|
||||||
|
json.put("name", name);
|
||||||
|
json.put("glCode", glCode);
|
||||||
|
json.put("usage", usage == HEADER ? "HEADER" : "DETAIL");
|
||||||
|
json.put("balance", balance());
|
||||||
|
final List<Map<String, Object>> childJson = new ArrayList<>();
|
||||||
|
for (final Node child : children) {
|
||||||
|
childJson.add(child.toJson());
|
||||||
|
}
|
||||||
|
json.put("children", childJson);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam,
|
||||||
|
@QueryParam("officeId") final Long officeId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate()
|
||||||
|
: LocalDate.parse(asOnDateParam);
|
||||||
|
|
||||||
|
final Map<Long, Node> nodesById = new LinkedHashMap<>();
|
||||||
|
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account "
|
||||||
|
+ "WHERE disabled = false ORDER BY gl_code")) {
|
||||||
|
final Node node = new Node();
|
||||||
|
node.id = ((Number) row.get("id")).longValue();
|
||||||
|
node.name = (String) row.get("name");
|
||||||
|
node.glCode = (String) row.get("gl_code");
|
||||||
|
node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue();
|
||||||
|
node.usage = ((Number) row.get("account_usage")).intValue();
|
||||||
|
node.classification = ((Number) row.get("classification_enum")).intValue();
|
||||||
|
nodesById.put(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry_date (not transaction_date) and "reversed = false" are this codebase's settled
|
||||||
|
// convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend
|
||||||
|
// reports and IncomeProfitTransferService all cut the same way, so this report agrees with
|
||||||
|
// them rather than quietly producing a second set of numbers.
|
||||||
|
//
|
||||||
|
// The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This
|
||||||
|
// codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the
|
||||||
|
// flag on the original leg (no counter entry - excluding it is exactly right), while
|
||||||
|
// Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and
|
||||||
|
// links it back through reversal_id. For that second kind, dropping only the original would
|
||||||
|
// leave its counter entry standing alone and swing the balance by the full amount in the
|
||||||
|
// wrong direction, so the counter entry has to go too.
|
||||||
|
final Map<Long, BigDecimal[]> balances = new LinkedHashMap<>();
|
||||||
|
final String balanceSql = "SELECT account_id, "
|
||||||
|
+ "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, "
|
||||||
|
+ "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit "
|
||||||
|
+ "FROM acc_gl_journal_entry WHERE entry_date <= ? "
|
||||||
|
+ "AND (reversed IS NULL OR reversed = false) "
|
||||||
|
+ "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)"
|
||||||
|
+ (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id";
|
||||||
|
final List<Map<String, Object>> balanceRows = officeId != null
|
||||||
|
? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId)
|
||||||
|
: this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate));
|
||||||
|
for (final Map<String, Object> row : balanceRows) {
|
||||||
|
balances.put(((Number) row.get("account_id")).longValue(),
|
||||||
|
new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Node node : nodesById.values()) {
|
||||||
|
if (node.usage != DETAIL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal[] debitCredit = balances.get(node.id);
|
||||||
|
if (debitCredit == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE;
|
||||||
|
node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Node> roots = new ArrayList<>();
|
||||||
|
for (final Node node : nodesById.values()) {
|
||||||
|
if (node.parentId == null) {
|
||||||
|
roots.add(node);
|
||||||
|
} else {
|
||||||
|
final Node parent = nodesById.get(node.parentId);
|
||||||
|
if (parent != null) {
|
||||||
|
parent.children.add(node);
|
||||||
|
} else {
|
||||||
|
// Parent was disabled (filtered out above) - surface this node at the top level
|
||||||
|
// rather than silently losing its balance from the report.
|
||||||
|
roots.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE);
|
||||||
|
final Map<String, Object> creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME);
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("asOnDate", asOnDate.toString());
|
||||||
|
response.put("officeId", officeId);
|
||||||
|
response.put("debitSide", debitSide);
|
||||||
|
response.put("creditSide", creditSide);
|
||||||
|
return this.gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildSide(final String label, final List<Node> roots, final int... classifications) {
|
||||||
|
final List<Map<String, Object>> groups = new ArrayList<>();
|
||||||
|
BigDecimal sideTotal = BigDecimal.ZERO;
|
||||||
|
for (final int classification : classifications) {
|
||||||
|
final List<Map<String, Object>> nodeJsons = new ArrayList<>();
|
||||||
|
BigDecimal groupTotal = BigDecimal.ZERO;
|
||||||
|
for (final Node root : roots) {
|
||||||
|
if (root.classification != classification) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodeJsons.add(root.toJson());
|
||||||
|
groupTotal = groupTotal.add(root.balance());
|
||||||
|
}
|
||||||
|
if (!nodeJsons.isEmpty()) {
|
||||||
|
final Map<String, Object> group = new LinkedHashMap<>();
|
||||||
|
group.put("classification", classificationLabel(classification));
|
||||||
|
group.put("total", groupTotal);
|
||||||
|
group.put("nodes", nodeJsons);
|
||||||
|
groups.add(group);
|
||||||
|
sideTotal = sideTotal.add(groupTotal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final Map<String, Object> side = new LinkedHashMap<>();
|
||||||
|
side.put("label", label);
|
||||||
|
side.put("total", sideTotal);
|
||||||
|
side.put("groups", groups);
|
||||||
|
return side;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classificationLabel(final int classification) {
|
||||||
|
return switch (classification) {
|
||||||
|
case ASSET -> "ASSET";
|
||||||
|
case LIABILITY -> "LIABILITY";
|
||||||
|
case EQUITY -> "EQUITY";
|
||||||
|
case INCOME -> "INCOME";
|
||||||
|
case EXPENSE -> "EXPENSE";
|
||||||
|
default -> "OTHER";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.common;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
|
||||||
|
* configures.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
|
||||||
|
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
|
||||||
|
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
|
||||||
|
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
|
||||||
|
* screen.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
|
||||||
|
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
|
||||||
|
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
|
||||||
|
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendGlAccountResolver {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** The year's closed-out profit a dividend is declared against. */
|
||||||
|
public Long distributableProfitAccount() {
|
||||||
|
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
|
||||||
|
public Long retainedProfitAccount() {
|
||||||
|
return requireMapped(FinancialActivity.RETAINED_PROFIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equity the declared profit is set aside in, between declaration and board approval. */
|
||||||
|
public Long reserveForDividendAccount() {
|
||||||
|
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The liability to members, created on approval and discharged as each member claims. */
|
||||||
|
public Long payableDividendsAccount() {
|
||||||
|
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the paise a dividend cannot divide evenly are swept.
|
||||||
|
*
|
||||||
|
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
|
||||||
|
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
|
||||||
|
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
|
||||||
|
* a decision the society has failed to make, which is why it resolves rather than throwing.
|
||||||
|
*/
|
||||||
|
public Long dividendRoundOffAccount() {
|
||||||
|
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
|
||||||
|
return roundOff != null ? roundOff : retainedProfitAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
|
||||||
|
*
|
||||||
|
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
|
||||||
|
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
|
||||||
|
* returns null instead of throwing.
|
||||||
|
*/
|
||||||
|
public Long dividendPaidAccountOrNull() {
|
||||||
|
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL mapped to an activity, or null when the society has not configured it. */
|
||||||
|
public Long mappedAccount(final FinancialActivity activity) {
|
||||||
|
return this.jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
activity.getValue())
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long requireMapped(final FinancialActivity activity) {
|
||||||
|
final Long glAccountId = mappedAccount(activity);
|
||||||
|
if (glAccountId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
|
||||||
|
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
|
||||||
|
+ "Mappings before running the dividend cycle.",
|
||||||
|
activity.getValue());
|
||||||
|
}
|
||||||
|
return glAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.glaccount.validation;
|
||||||
|
|
||||||
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountUsage;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Header GL account (account_usage = HEADER) is a pure grouping node in the chart-of-accounts tree - it
|
||||||
|
* exists to organize Detail accounts underneath it, never to receive a transaction itself. The standard
|
||||||
|
* Fineract journal-entry command path (JournalEntryWritePlatformServiceJpaRepositoryImpl) never checked
|
||||||
|
* this - only "disabled" and "manual entries allowed" - and several features in this codebase post journal
|
||||||
|
* entries via direct JDBC INSERT into acc_gl_journal_entry, bypassing that command path entirely. This is
|
||||||
|
* the one shared check every such call site uses so a Header account can never end up with its own
|
||||||
|
* transaction (which would silently break every report that assumes a Header's balance is just the sum of
|
||||||
|
* its Detail children).
|
||||||
|
*/
|
||||||
|
public final class GLAccountTransactionGuard {
|
||||||
|
|
||||||
|
private GLAccountTransactionGuard() {}
|
||||||
|
|
||||||
|
public static void assertPostable(final JdbcTemplate jdbcTemplate, final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Integer usage;
|
||||||
|
try {
|
||||||
|
usage = jdbcTemplate.queryForObject("SELECT account_usage FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.glaccount.not.found",
|
||||||
|
"GL account with id " + glAccountId + " does not exist.", "glAccountId", glAccountId);
|
||||||
|
}
|
||||||
|
if (GLAccountUsage.HEADER.getValue().equals(usage)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.glaccount.header.account.not.postable",
|
||||||
|
"GL account " + glAccountId + " is a Header account (a chart-of-accounts grouping node) and cannot receive a "
|
||||||
|
+ "transaction directly. Select a Detail account instead.",
|
||||||
|
"glAccountId", glAccountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1667
-1611
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
||||||
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
||||||
.append(" eao.external_id as externalAssetOwner, ")
|
.append(" eao.external_id as externalAssetOwner, ")
|
||||||
|
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
|
||||||
|
// m_current_account -- newer write paths tag current-account entries with it
|
||||||
|
// instead of reusing SAVING's type, specifically so this case needs no guessing.
|
||||||
|
//
|
||||||
|
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
|
||||||
|
// and current-account entries by older write paths not yet updated to the above --
|
||||||
|
// current products used to deliberately share SAVING's GL type space (see
|
||||||
|
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
|
||||||
|
// m_current_account each have their own id sequence, so an entity_id can be a real
|
||||||
|
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
|
||||||
|
// account's number even for an entry that is genuinely a savings one.
|
||||||
|
// savings_transaction_id is the one unambiguous signal available for those older
|
||||||
|
// entries -- only a real savings transaction sets it -- so it decides first; only
|
||||||
|
// when it's absent does the current-account guess apply.
|
||||||
.append(" CASE ")
|
.append(" CASE ")
|
||||||
|
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
|
||||||
|
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
||||||
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
||||||
.append(" ELSE NULL ")
|
.append(" ELSE NULL ")
|
||||||
.append(" END as savingsAccountNo ");
|
.append(" END as savingsAccountNo ");
|
||||||
@@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
||||||
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
||||||
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
||||||
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
|
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
|
||||||
if (associationParametersData.isTransactionDetailsRequired()) {
|
if (associationParametersData.isTransactionDetailsRequired()) {
|
||||||
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
||||||
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
||||||
|
|||||||
+74
-1
@@ -21,6 +21,7 @@ package org.apache.fineract.accounting.journalentry.service;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -391,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
||||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
||||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||||
|
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
|
||||||
|
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
|
||||||
|
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +631,12 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
|
final boolean cashBasedAccountingEnabled = (Boolean) accountingBridgeData.get("cashBasedAccountingEnabled");
|
||||||
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
|
final boolean accrualBasedAccountingEnabled = (Boolean) accountingBridgeData.get("accrualBasedAccountingEnabled");
|
||||||
|
|
||||||
if (cashBasedAccountingEnabled) {
|
// A register that knows the transaction's real counterparty posts the entry itself, in one
|
||||||
|
// balanced pair against that account, and says so by wrapping the command in
|
||||||
|
// ShareAccountingSuppression.withOwnShareEntries(). Posting here as well would add a second
|
||||||
|
// entry for the same money, booked against the product's fixed Shares Reference account --
|
||||||
|
// which is what that register is avoiding in the first place.
|
||||||
|
if (cashBasedAccountingEnabled && ShareAccountingSuppression.shouldPostShareEntries()) {
|
||||||
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
|
final SharesDTO sharesDTO = this.helper.populateSharesDtoFromMap(accountingBridgeData, cashBasedAccountingEnabled,
|
||||||
accrualBasedAccountingEnabled);
|
accrualBasedAccountingEnabled);
|
||||||
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
|
final AccountingProcessorForShares accountingProcessorForShares = this.accountingProcessorForSharesFactory
|
||||||
@@ -775,15 +784,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
||||||
|
|
||||||
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
||||||
|
validateNoDenominationManagedCashAccounts(journalEntryCommand);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* revert old journal entries
|
* revert old journal entries
|
||||||
|
*
|
||||||
|
* A transaction that carries a leg against the denomination-managed cash account (see
|
||||||
|
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
|
||||||
|
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
|
||||||
|
* entry against this same Opening Balances Contra account), so the query above finds it
|
||||||
|
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
|
||||||
|
* leg, would silently erase the cash account's opening balance every time an admin
|
||||||
|
* redefines opening balances for any OTHER account at this office.
|
||||||
*/
|
*/
|
||||||
|
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
||||||
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
||||||
officeId);
|
officeId);
|
||||||
for (String transactionId : transactionIdsToBeReversed) {
|
for (String transactionId : transactionIdsToBeReversed) {
|
||||||
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
||||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||||
|
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
|
||||||
|
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
|
||||||
|
if (touchesDenominationManagedCashAccount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
revertJournalEntry(journalEntries, "defining opening balance");
|
revertJournalEntry(journalEntries, "defining opening balance");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,6 +889,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared
|
||||||
|
* by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly
|
||||||
|
* which account(s) Migrate Cash Denomination owns.
|
||||||
|
*/
|
||||||
|
private List<Long> resolveDenominationManagedCashGlAccountIds() {
|
||||||
|
final List<Long> ids = new ArrayList<>();
|
||||||
|
for (final FinancialActivityAccount mapping : this.financialActivityAccountRepositoryWrapper.findAll()) {
|
||||||
|
final Integer type = mapping.getFinancialActivityType();
|
||||||
|
if ((type != null && (type == 101 || type == 102)) && mapping.getGlAccount() != null) {
|
||||||
|
ids.add(mapping.getGlAccount().getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account(s) mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) are
|
||||||
|
* managed exclusively through Migrate Cash Denomination (BranchDayRegisterApiResource#migrateCashDenomination),
|
||||||
|
* which keeps the GL figure tied to an actual counted note-and-coin breakdown, and validates any
|
||||||
|
* redefinition against that same count. Defining or redefining that account's opening balance here
|
||||||
|
* instead - as a bare number, with no denomination breakdown behind it - would let the GL figure
|
||||||
|
* drift away from what is physically in the till, with nothing left to catch the mismatch. So this
|
||||||
|
* command refuses to touch either of those accounts at all, on either side of the entry, rather than
|
||||||
|
* only when the amount would differ from what is already posted: the physical count is the only
|
||||||
|
* source of truth for this account, never a number typed into a generic opening-balance grid.
|
||||||
|
*/
|
||||||
|
private void validateNoDenominationManagedCashAccounts(final JournalEntryCommand command) {
|
||||||
|
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
||||||
|
if (denominationManagedGlAccountIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<SingleDebitOrCreditEntryCommand> allEntries = new ArrayList<>();
|
||||||
|
if (command.getDebits() != null) {
|
||||||
|
allEntries.addAll(Arrays.asList(command.getDebits()));
|
||||||
|
}
|
||||||
|
if (command.getCredits() != null) {
|
||||||
|
allEntries.addAll(Arrays.asList(command.getCredits()));
|
||||||
|
}
|
||||||
|
for (final SingleDebitOrCreditEntryCommand entry : allEntries) {
|
||||||
|
if (denominationManagedGlAccountIds.contains(entry.getGlAccountId())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.journalentry.opening.balance.cash.account.locked",
|
||||||
|
"This account is the tenant's Cash at Vault / Cash at Teller account and is managed through "
|
||||||
|
+ "Migrate Cash Denomination - it cannot be set or changed from Migrate Opening Balances.",
|
||||||
|
entry.getGlAccountId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
||||||
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.journalentry.service;
|
||||||
|
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints the transaction id that ties the rows of one posting together in acc_gl_journal_entry.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Every feature here that posts a journal entry by direct JDBC used to invent this id by hand, and the two
|
||||||
|
* shapes that emerged were each wrong in their own way. Some concatenated a business row's own primary key
|
||||||
|
* ({@code "DIVROUND" + dividendPayOutId}); a primary key is only unique while its sequence keeps climbing,
|
||||||
|
* so a reset hands the same number out twice - which is exactly how two unrelated share withdrawals came to
|
||||||
|
* share one transaction id and render as a single doubled voucher. Others concatenated
|
||||||
|
* {@code System.nanoTime()}, which is unique but says nothing to whoever reads it off a screen
|
||||||
|
* ({@code "CASHMIG-1-20260913-923935527932300"}).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This generator replaces both with one readable shape, {@code PREFIX-YYMMDD-NNNN}:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* SHWGL-260916-0001 the first share-withdrawal GL settlement posted on 16 Sep 2026
|
||||||
|
* DIVRND-260916-0007 the seventh dividend rounding posted that day
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* The prefix names the event, the date is the tenant's business date, and the serial restarts at 0001 each
|
||||||
|
* day. That makes a transaction id self-describing: a reader can tell what it was and when it happened
|
||||||
|
* without looking anything up.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Uniqueness does not depend on any business table. The serial comes from {@code m_ledger_txn_sequence}, a
|
||||||
|
* counter that belongs to no entity, so no entity's sequence can make it repeat. The counter is bumped by a
|
||||||
|
* single {@code INSERT ... ON CONFLICT DO UPDATE}, which takes and releases the row lock atomically, so two
|
||||||
|
* users posting at the same instant are serialised by the database rather than handed the same number - the
|
||||||
|
* same mechanism {@code m_voucher_sequence} already uses for voucher numbers.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The counter alone would still repeat if that table were ever truncated or rewound by hand, which is
|
||||||
|
* precisely the accident being defended against, so the minted id is also checked against the ledger before
|
||||||
|
* being handed back and the counter is advanced past anything already in use. That makes a duplicate
|
||||||
|
* impossible by construction rather than by convention: the id is only returned once no row holds it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Ids already in the ledger keep their old shape. Nothing reconstructs a custom id to find its rows again
|
||||||
|
* (every call site mints one, posts with it, and never derives it a second time), so old and new shapes
|
||||||
|
* coexist without anything needing to read both.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Postings made by Fineract's own command path are not covered here and keep their native
|
||||||
|
* {@code "S"/"L"/"SH"/"C" + transaction id} form, which {@code AccountingProcessorHelper} derives and
|
||||||
|
* several readers reconstruct. Those readers are hardened at the query instead, by resolving to the most
|
||||||
|
* recently posted rows rather than assuming the text is unique.
|
||||||
|
*/
|
||||||
|
public final class LedgerTransactionId {
|
||||||
|
|
||||||
|
/** Guards against an impossible-in-practice runaway if the ledger and the counter disagree. */
|
||||||
|
private static final int MAX_PROBES = 50;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter YYMMDD = DateTimeFormatter.ofPattern("yyMMdd");
|
||||||
|
|
||||||
|
private LedgerTransactionId() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next transaction id for this kind of posting, e.g. {@code SHWGL-260916-0001}.
|
||||||
|
*
|
||||||
|
* @param prefix
|
||||||
|
* short uppercase code naming the event, from {@link LedgerTxnPrefix}
|
||||||
|
*/
|
||||||
|
public static String next(final JdbcTemplate jdbcTemplate, final String prefix) {
|
||||||
|
// Tenant timezone, not the server's - the day the counter resets on has to be the branch's
|
||||||
|
// business day, otherwise a late-evening posting lands in the wrong series.
|
||||||
|
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final String datePart = today.format(YYMMDD);
|
||||||
|
|
||||||
|
for (int probe = 0; probe < MAX_PROBES; probe++) {
|
||||||
|
final int serial = nextSerial(jdbcTemplate, prefix, today);
|
||||||
|
final String candidate = String.format("%s-%s-%04d", prefix, datePart, serial);
|
||||||
|
if (!isTaken(jdbcTemplate, candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
// Only reachable if the counter was rewound behind the ledger by hand. Bumping again walks
|
||||||
|
// it forward until it clears whatever is already posted, which self-heals the counter.
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Could not mint a free ledger transaction id for prefix " + prefix + " after " + MAX_PROBES
|
||||||
|
+ " attempts. The m_ledger_txn_sequence counter for this prefix is far behind the entries already posted today.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the id for a posting that has to be reproducible, e.g. {@code SAVINT-260916-P0002}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Some postings are not one-shot: a pooled interest run pays hundreds of members, and a run that
|
||||||
|
* stops halfway is resumed rather than restarted. Those resumed legs belong to the posting that
|
||||||
|
* started them, so the id has to come out the same on the second pass - a counter would hand the
|
||||||
|
* remainder a different id and split one posting across two transactions. This builds the id from
|
||||||
|
* what identifies the run instead: the same prefix, date and discriminator always produce the same
|
||||||
|
* string, and no counter is consumed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Uniqueness rests on the caller's own discriminator being unique for that prefix and date (the
|
||||||
|
* product being posted, say), which is what makes the id reproducible in the first place. Use
|
||||||
|
* {@link #next} for everything else - a posting that happens once wants a counter, not a formula.
|
||||||
|
* The {@code P} marks the tail as a discriminator so it is not misread as a serial.
|
||||||
|
*
|
||||||
|
* @param date
|
||||||
|
* the date the posting belongs to, which is not always today - interest can be posted
|
||||||
|
* for an earlier day
|
||||||
|
* @param discriminator
|
||||||
|
* what makes this run distinct from another on the same date, e.g. the product id
|
||||||
|
*/
|
||||||
|
public static String forDatedSeries(final String prefix, final LocalDate date, final long discriminator) {
|
||||||
|
return String.format("%s-%s-P%04d", prefix, date.format(YYMMDD), discriminator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves the next counter value for one (prefix, day) series.
|
||||||
|
*
|
||||||
|
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use of the day and
|
||||||
|
* increments it thereafter. Being a single statement it is atomic, so no explicit lock is needed.
|
||||||
|
*/
|
||||||
|
private static int nextSerial(final JdbcTemplate jdbcTemplate, final String prefix, final LocalDate day) {
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_ledger_txn_sequence (prefix, series_date, last_number)
|
||||||
|
VALUES (?,?,1)
|
||||||
|
ON CONFLICT (prefix, series_date)
|
||||||
|
DO UPDATE SET last_number = m_ledger_txn_sequence.last_number + 1
|
||||||
|
RETURNING last_number
|
||||||
|
""", Integer.class, prefix, Date.valueOf(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTaken(final JdbcTemplate jdbcTemplate, final String transactionId) {
|
||||||
|
final Boolean taken = jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM acc_gl_journal_entry WHERE transaction_id = ?)",
|
||||||
|
Boolean.class, transactionId);
|
||||||
|
return Boolean.TRUE.equals(taken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.journalentry.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event codes that open a transaction id minted by {@link LedgerTransactionId}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* They live together in one place so a reader can decode any transaction id on a screen from a single list,
|
||||||
|
* and so no two features can drift into naming the same event differently - which had already happened:
|
||||||
|
* dividend forfeiture was posting under {@code DIVFORF} from one service and {@code DIVFORFEIT} from
|
||||||
|
* another, for the same event.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Codes are uppercase, up to eight characters, and grouped by the module that raises them. A code is part of
|
||||||
|
* the audit trail once it has been posted, so treat these as append-only: add a new one for a new event
|
||||||
|
* rather than renaming an existing one, which would leave already-posted entries speaking a language nothing
|
||||||
|
* else uses any more.
|
||||||
|
*/
|
||||||
|
public final class LedgerTxnPrefix {
|
||||||
|
|
||||||
|
private LedgerTxnPrefix() {}
|
||||||
|
|
||||||
|
// A clerk's share withdrawal has no code here on purpose: it posts under the transaction id the
|
||||||
|
// native redeem command already minted ("SH" + share transaction id), because its debit leg is
|
||||||
|
// that command's own and the two have to group as one balanced entry.
|
||||||
|
|
||||||
|
// ── Share tranche funding ─────────────────────────────────────────────────
|
||||||
|
/** Additional share tranche: reversal of the stock reference posting. */
|
||||||
|
public static final String SHARE_TRANCHE_REFERENCE = "SHTREF";
|
||||||
|
/** Additional share tranche funded from the member's savings account. */
|
||||||
|
public static final String SHARE_TRANCHE_SAVINGS = "SHTSAV";
|
||||||
|
|
||||||
|
// ── Share collection and advances ─────────────────────────────────────────
|
||||||
|
/** Share application money collected. */
|
||||||
|
public static final String SHARE_COLLECTION = "SHCOL";
|
||||||
|
/** Share advance collected against a future allotment. */
|
||||||
|
public static final String SHARE_ADVANCE = "SHADV";
|
||||||
|
/** Share advance applied to an allotment. */
|
||||||
|
public static final String SHARE_ADVANCE_APPLIED = "SHAPP";
|
||||||
|
|
||||||
|
// ── Dividend ──────────────────────────────────────────────────────────────
|
||||||
|
/** Dividend claim paid out to a member. */
|
||||||
|
public static final String DIVIDEND_CLAIM = "DIVCLM";
|
||||||
|
/** Dividend declaration posted. */
|
||||||
|
public static final String DIVIDEND_DECLARED = "DIVDCL";
|
||||||
|
/** Dividend declaration approved. */
|
||||||
|
public static final String DIVIDEND_APPROVED = "DIVAPR";
|
||||||
|
/** Dividend payout closed off. */
|
||||||
|
public static final String DIVIDEND_CLOSED = "DIVCLS";
|
||||||
|
/** Rounding difference on a dividend payout. */
|
||||||
|
public static final String DIVIDEND_ROUNDING = "DIVRND";
|
||||||
|
/** Unclaimed dividend forfeited. */
|
||||||
|
public static final String DIVIDEND_FORFEIT = "DIVFRF";
|
||||||
|
|
||||||
|
// ── Branch day and cash ───────────────────────────────────────────────────
|
||||||
|
/** Opening cash migrated into the branch day register. */
|
||||||
|
public static final String CASH_MIGRATION = "CSHMIG";
|
||||||
|
/** Rounding difference on a clerk's day closure. */
|
||||||
|
public static final String CLOSURE_ROUNDING = "CLSRND";
|
||||||
|
|
||||||
|
// ── Year end ──────────────────────────────────────────────────────────────
|
||||||
|
/** Year-end transfer of income into the profit account. */
|
||||||
|
public static final String YEAR_END_PROFIT = "YEPFT";
|
||||||
|
/** Year-end closure of an income or expense account. */
|
||||||
|
public static final String YEAR_END_CLOSE = "YECLS";
|
||||||
|
|
||||||
|
// ── Savings ───────────────────────────────────────────────────────────────
|
||||||
|
/** Pooled savings interest posting for one product and date. */
|
||||||
|
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.journalentry.service;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a register that posts a share transaction's accounting itself tell the platform not to post its
|
||||||
|
* own.
|
||||||
|
*
|
||||||
|
* <h3>Why this exists</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Fineract books every share transaction against a fixed "Shares Reference" account taken from the
|
||||||
|
* product's GL mapping: a redemption posts Dr Shares Equity / Cr Shares Reference, whatever the money
|
||||||
|
* actually did. That account is a placeholder for a counterparty the platform does not know, and in a
|
||||||
|
* real core banking ledger there is no such thing — a redemption paid in cash credits the cash
|
||||||
|
* account, and one settled by transfer credits the account it was transferred to. Naming a single
|
||||||
|
* asset account for both leaves every redemption sitting against a GL the money never touched, which
|
||||||
|
* then has to be corrected by hand.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The governed clerk registers already know the real counterparty, because the clerk keyed it: the
|
||||||
|
* linked savings account, or the GL picked on the transfer's credit row. They post the redemption
|
||||||
|
* themselves, correctly, in one balanced entry. Before this flag existed they had to let the platform
|
||||||
|
* post its version first and then reverse the half that was wrong, which left a dead Cr Shares
|
||||||
|
* Reference row behind on every redemption and made the ledger read as though cash had moved.
|
||||||
|
*
|
||||||
|
* <h3>Why a flag and not the product's accounting rule</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Turning the share product's accounting off entirely ({@code m_share_product.accounting_type = NONE})
|
||||||
|
* would silence the platform everywhere, including the share account screen's own Redeem action, which
|
||||||
|
* no register covers — those redemptions would post nothing at all and go unnoticed, because that
|
||||||
|
* branch fails silently by design. Scoping the suppression to the one call that is about to post its
|
||||||
|
* own entry leaves every other path exactly as it is.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A ThreadLocal rather than a request attribute, and restoring the previous value rather than clearing
|
||||||
|
* it, for the same reasons as {@code VoucherService.withOwnVoucher} — a batch job has no request to
|
||||||
|
* hang the flag on, and nesting must not switch the suppression off early for an outer caller.
|
||||||
|
*/
|
||||||
|
public final class ShareAccountingSuppression {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Boolean> CALLER_POSTS_SHARE_ENTRIES = ThreadLocal.withInitial(() -> Boolean.FALSE);
|
||||||
|
|
||||||
|
private ShareAccountingSuppression() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@code command} with the platform's own share journal entries suppressed, for a caller that
|
||||||
|
* posts the transaction's accounting itself.
|
||||||
|
*/
|
||||||
|
public static <T> T withOwnShareEntries(final Supplier<T> command) {
|
||||||
|
final boolean previous = CALLER_POSTS_SHARE_ENTRIES.get();
|
||||||
|
CALLER_POSTS_SHARE_ENTRIES.set(Boolean.TRUE);
|
||||||
|
try {
|
||||||
|
return command.get();
|
||||||
|
} finally {
|
||||||
|
CALLER_POSTS_SHARE_ENTRIES.set(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** False only while a caller inside {@link #withOwnShareEntries} is posting its own entries. */
|
||||||
|
public static boolean shouldPostShareEntries() {
|
||||||
|
return !CALLER_POSTS_SHARE_ENTRIES.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
|||||||
break;
|
break;
|
||||||
case CASH_BASED:
|
case CASH_BASED:
|
||||||
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
|
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
|
||||||
|
// Interest Payable is saved on cash too, and optionally: interest posting routes
|
||||||
|
// through it on either basis (see AccountingProcessorHelper
|
||||||
|
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
|
||||||
|
// yet credited to the member reads as the bank's own liability rather than appearing
|
||||||
|
// only at the moment it is paid. Mapped only when the screen supplies it - a product
|
||||||
|
// naming none still posts straight to Savings Control, as it always did.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||||
|
element)) {
|
||||||
|
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||||
|
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
|
||||||
|
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
|
|||||||
+7
-7
@@ -37,6 +37,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -60,13 +61,12 @@ import org.springframework.stereotype.Component;
|
|||||||
public class DistributableProfitApiResource {
|
public class DistributableProfitApiResource {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
|
* The year's closed-out profit, which is what a dividend may be declared against.
|
||||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
|
||||||
*
|
*
|
||||||
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
|
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
|
||||||
* offer profit already distributed once for distribution again.
|
* offer profit already distributed once for distribution again.
|
||||||
*/
|
*/
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||||
|
|
||||||
@@ -162,9 +162,9 @@ public class DistributableProfitApiResource {
|
|||||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||||
FROM acc_gl_journal_entry je
|
FROM acc_gl_journal_entry je
|
||||||
JOIN acc_gl_account a ON a.id = je.account_id
|
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
|
||||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
java.sql.Date.valueOf(range[1]));
|
||||||
return balance == null ? BigDecimal.ZERO : balance;
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ public class IncomeProfitTransferApiResource {
|
|||||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||||
: incomeGlCodeParam.trim();
|
: incomeGlCodeParam.trim();
|
||||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||||
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
|
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
|
||||||
|
|
||||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||||
: financialYearParam.trim();
|
: financialYearParam.trim();
|
||||||
|
|||||||
+38
-17
@@ -10,6 +10,10 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
|
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||||
|
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
@@ -22,7 +26,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* Dr Share Entry Fee Income (10.4) = balance for the year
|
* Dr Share Entry Fee Income (10.4) = balance for the year
|
||||||
* Cr Bank Profit (BANKPROFIT01)
|
* Cr Distributable Profit (the GL mapped to that financial activity)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
||||||
@@ -42,22 +46,23 @@ public class IncomeProfitTransferService {
|
|||||||
private static final int DEBIT = 2;
|
private static final int DEBIT = 2;
|
||||||
private static final int CREDIT = 1;
|
private static final int CREDIT = 1;
|
||||||
|
|
||||||
/** Equity GL that accumulates profit. */
|
|
||||||
/**
|
|
||||||
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
|
|
||||||
*
|
|
||||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
|
|
||||||
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
|
|
||||||
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
|
||||||
* decision — offering the same money for distribution twice.
|
|
||||||
*/
|
|
||||||
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
|
||||||
|
|
||||||
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
||||||
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year's profit is closed into the Distributable Profit account, not the Retained one.
|
||||||
|
*
|
||||||
|
* A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit
|
||||||
|
* once the declaration is approved. Closing into Retained directly would mix the year just ended with earlier
|
||||||
|
* years' leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||||
|
* decision — offering the same money for distribution twice.
|
||||||
|
*
|
||||||
|
* Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01.
|
||||||
|
*/
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
||||||
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
||||||
@@ -72,7 +77,7 @@ public class IncomeProfitTransferService {
|
|||||||
final String note, final boolean strict) {
|
final String note, final boolean strict) {
|
||||||
|
|
||||||
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
||||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||||
final Long profitGlId = toLong(profitGl.get("id"));
|
final Long profitGlId = toLong(profitGl.get("id"));
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ public class IncomeProfitTransferService {
|
|||||||
final String currencyCode = resolveCurrency(incomeGlId);
|
final String currencyCode = resolveCurrency(incomeGlId);
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
final LocalDate postingDate = range[1];
|
final LocalDate postingDate = range[1];
|
||||||
final String txnId = "YEPROFIT-" + System.nanoTime();
|
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_PROFIT);
|
||||||
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
||||||
+ (note != null ? " | " + note : "");
|
+ (note != null ? " | " + note : "");
|
||||||
|
|
||||||
@@ -165,7 +170,7 @@ public class IncomeProfitTransferService {
|
|||||||
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
||||||
final boolean strict) {
|
final boolean strict) {
|
||||||
|
|
||||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
final Long profitGlId = toLong(profitGl.get("id"));
|
final Long profitGlId = toLong(profitGl.get("id"));
|
||||||
final LocalDate[] range = financialYearRange(financialYear);
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
final Long officeId = headOfficeId();
|
final Long officeId = headOfficeId();
|
||||||
@@ -207,7 +212,7 @@ public class IncomeProfitTransferService {
|
|||||||
final String currencyCode = resolveCurrency(glId);
|
final String currencyCode = resolveCurrency(glId);
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
final LocalDate postingDate = range[1];
|
final LocalDate postingDate = range[1];
|
||||||
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
|
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.YEAR_END_CLOSE);
|
||||||
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
||||||
+ (note != null ? " | " + note : "");
|
+ (note != null ? " | " + note : "");
|
||||||
|
|
||||||
@@ -283,7 +288,7 @@ public class IncomeProfitTransferService {
|
|||||||
*/
|
*/
|
||||||
public BigDecimal profitForFinancialYear(final String financialYear) {
|
public BigDecimal profitForFinancialYear(final String financialYear) {
|
||||||
final LocalDate[] range = financialYearRange(financialYear);
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
||||||
@@ -342,6 +347,21 @@ public class IncomeProfitTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** As {@link #loadGlByCode}, for an account already identified by a financial activity mapping. */
|
||||||
|
public Map<String, Object> loadGlById(final Long glAccountId) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found",
|
||||||
|
"GL account " + glAccountId + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distributable-profit GL the year's income closes into, as configured on Financial Activity Mappings. */
|
||||||
|
public Map<String, Object> loadDistributableProfitGl() {
|
||||||
|
return loadGlById(this.dividendGlAccounts.distributableProfitAccount());
|
||||||
|
}
|
||||||
|
|
||||||
public Long headOfficeId() {
|
public Long headOfficeId() {
|
||||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
}
|
}
|
||||||
@@ -359,6 +379,7 @@ public class IncomeProfitTransferService {
|
|||||||
|
|
||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
+ " entry_date, type_enum, amount, description, "
|
+ " entry_date, type_enum, amount, description, "
|
||||||
|
|||||||
+3
@@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO;
|
|||||||
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
import org.apache.fineract.cob.service.LoanCOBCatchUpService;
|
||||||
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException;
|
||||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/loans")
|
@Path("/v1/loans")
|
||||||
@@ -44,6 +45,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class LoanCOBCatchUpApiResource {
|
public class LoanCOBCatchUpApiResource {
|
||||||
|
|
||||||
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
private final Optional<LoanCOBCatchUpService> loanCOBCatchUpServiceOp;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("oldest-cob-closed")
|
@Path("oldest-cob-closed")
|
||||||
@@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource {
|
|||||||
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
@ApiResponse(responseCode = "202", description = "Catch Up has been started")
|
||||||
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
@ApiResponse(responseCode = "400", description = "Catch Up is already running")
|
||||||
public Response executeLoanCOBCatchUp() {
|
public Response executeLoanCOBCatchUp() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP");
|
||||||
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> {
|
||||||
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||||
|
|||||||
+58
-9
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma
|
|||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
||||||
|
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
||||||
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
||||||
@@ -164,9 +165,11 @@ public class SecurityConfig {
|
|||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||||
// Client account summary, third-party callers — a partner integration calls this
|
// Client account summary, third-party callers — a partner integration calls this
|
||||||
// with an X-Api-Key header instead of a Fineract username/password (issued via
|
// with an X-Api-Key header instead of a Fineract username/password. The key itself
|
||||||
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
// is issued by inserting directly into m_api_client in the tenant-store database
|
||||||
// outer gate stays open for this one narrow GET path, and
|
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
|
||||||
|
// since any bank's own super user reaching it could mint a platform-wide key). Same
|
||||||
|
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
|
||||||
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||||
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
||||||
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||||
@@ -221,7 +224,10 @@ public class SecurityConfig {
|
|||||||
// currency
|
// currency
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies"))
|
// the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) -
|
||||||
|
// this matcher previously targeted POST, which meant PUT fell through to the generic
|
||||||
|
// fullyAuthenticated() catch-all with no permission check at all.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY")
|
||||||
// notes: read
|
// notes: read
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes"))
|
||||||
@@ -354,11 +360,18 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT")
|
||||||
// payment type
|
// payment type
|
||||||
|
// NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a
|
||||||
|
// second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and
|
||||||
|
// no PUT/DELETE matcher existed at all, so those requests fell through to the generic
|
||||||
|
// fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher
|
||||||
|
// per method, each requiring its own correct permission code.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE")
|
||||||
// mix: taxonomy
|
// mix: taxonomy
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*"))
|
||||||
@@ -389,12 +402,41 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT")
|
||||||
// working days
|
// working days
|
||||||
|
// NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between
|
||||||
|
// WORKING and DAYS), which did not match the actual seeded permission codes
|
||||||
|
// (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS")
|
||||||
|
// sponsor banks
|
||||||
|
// NOTE: no matcher previously existed at all for this path - POST/PUT both fell
|
||||||
|
// through to the generic fullyAuthenticated() catch-all below with no permission
|
||||||
|
// check whatsoever.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK")
|
||||||
|
// sponsor DD GST/GL settings
|
||||||
|
// NOTE: no matcher previously existed at all for this path either. gl-mapping is a
|
||||||
|
// genuine upsert (one call both creates and updates the single settings row), so it is
|
||||||
|
// covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING")
|
||||||
|
// bank profile (institution profile)
|
||||||
|
// NOTE: no matcher previously existed at all for this path - the PUT relied solely on
|
||||||
|
// the in-resource validateHasPermissionTo() check, which this change replaces with the
|
||||||
|
// command-source pipeline. Added here for the same defense-in-depth already applied to
|
||||||
|
// Currency/Payment Type/Working Days.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE")
|
||||||
|
// branch profile
|
||||||
|
// NOTE: no matcher previously existed at all for this path either, same rationale as
|
||||||
|
// institution profile above.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE")
|
||||||
|
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
|
||||||
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
|
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
|
||||||
@@ -426,7 +468,10 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
||||||
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
||||||
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
||||||
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
|
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
|
||||||
|
// Refuses the handful of accounting features switched off for this installation.
|
||||||
|
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
|
||||||
|
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
|
||||||
|
|
||||||
if (loanCOBFilterHelper != null) {
|
if (loanCOBFilterHelper != null) {
|
||||||
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
||||||
@@ -463,6 +508,10 @@ public class SecurityConfig {
|
|||||||
return new RequestResponseFilter();
|
return new RequestResponseFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
|
||||||
|
return new DisabledFeatureApiFilter();
|
||||||
|
}
|
||||||
|
|
||||||
public LoanCOBApiFilter loanCOBApiFilter() {
|
public LoanCOBApiFilter loanCOBApiFilter() {
|
||||||
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
||||||
}
|
}
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.core.filters;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off a handful of accounting features at the HTTP edge, without deleting any of the code
|
||||||
|
* behind them: the resources, services and command handlers all stay exactly as they are, and only
|
||||||
|
* an inbound API call is refused. Re-enabling a feature is deleting its rule from RULES below.
|
||||||
|
*
|
||||||
|
* Blocking here rather than inside the resources matters for one specific reason: several internal
|
||||||
|
* flows raise journal entries through CommandWrapperBuilder().createJournalEntry() in Java
|
||||||
|
* (ClerkDisbursementRequestApiResource's GL offsets, clerk closure rounding, bank transfers). Those
|
||||||
|
* never travel over HTTP, so they keep working, while the "Create Journal Entries" screen and any
|
||||||
|
* direct call to the endpoint are refused.
|
||||||
|
*
|
||||||
|
* A rule carries an optional set of allowed "command" query parameter values, because
|
||||||
|
* POST /v1/journalentries is three different features behind one path: no command is the manual
|
||||||
|
* journal entry screen (blocked), command=defineOpeningBalance is Migrate Opening Balances and
|
||||||
|
* command=updateRunningBalance is the running-balance maintenance job (both still needed).
|
||||||
|
*/
|
||||||
|
public class DisabledFeatureApiFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private record Rule(String feature, Set<String> methods, Pattern path, Set<String> allowedCommands) {
|
||||||
|
|
||||||
|
boolean matches(final String method, final String pathInfo, final String command) {
|
||||||
|
if (!methods.isEmpty() && !methods.contains(method)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!path.matcher(pathInfo).matches()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A command this rule explicitly spares (a different feature sharing the same path).
|
||||||
|
return !(command != null && allowedCommands.contains(command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Rule> RULES = List.of(
|
||||||
|
// Create Journal Entries, and Frequent Postings which posts through the same endpoint.
|
||||||
|
new Rule("Create Journal Entries", Set.of("POST"), Pattern.compile("/v[1-9][0-9]*/journalentries/?"),
|
||||||
|
Set.of("defineOpeningBalance", "updateRunningBalance")),
|
||||||
|
// Closing Entries.
|
||||||
|
new Rule("Closing Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/glclosures(/.*)?"), Set.of()),
|
||||||
|
// Accruals.
|
||||||
|
new Rule("Accruals", Set.of(), Pattern.compile("/v[1-9][0-9]*/runaccruals(/.*)?"), Set.of()),
|
||||||
|
// Provisioning Entries, including the journal entries screen that belongs to it.
|
||||||
|
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/provisioningentries(/.*)?"), Set.of()),
|
||||||
|
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/journalentries/provisioning/?"), Set.of()),
|
||||||
|
// Accounting Rules. Only the Accounting Rules screens and Frequent Postings (disabled
|
||||||
|
// above) read these, so nothing still reachable depends on them.
|
||||||
|
new Rule("Accounting Rules", Set.of(), Pattern.compile("/v[1-9][0-9]*/accountingrules(/.*)?"), Set.of()));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
final String pathInfo = request.getPathInfo();
|
||||||
|
if (StringUtils.isBlank(pathInfo)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String method = request.getMethod();
|
||||||
|
// Read "command" out of the query string rather than through getParameter(): the latter
|
||||||
|
// triggers servlet parameter parsing, which for a form-encoded or multipart POST consumes
|
||||||
|
// the request body before the resource ever sees it. This filter sits in front of every
|
||||||
|
// API call, so it must not touch the body of requests it has no interest in.
|
||||||
|
final String command = commandFromQueryString(request.getQueryString());
|
||||||
|
for (final Rule rule : RULES) {
|
||||||
|
if (rule.matches(method, pathInfo, command)) {
|
||||||
|
reject(response, rule.feature());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String commandFromQueryString(final String queryString) {
|
||||||
|
if (StringUtils.isBlank(queryString)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final String pair : queryString.split("&")) {
|
||||||
|
if (pair.startsWith("command=")) {
|
||||||
|
return URLDecoder.decode(pair.substring("command=".length()), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reject(final HttpServletResponse response, final String feature) throws IOException {
|
||||||
|
response.setStatus(HttpStatus.SC_FORBIDDEN);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.getWriter()
|
||||||
|
.write("{\"developerMessage\":\"" + feature + " is disabled on this installation.\","
|
||||||
|
+ "\"httpStatusCode\":\"403\",\"defaultUserMessage\":\"" + feature + " is not available.\","
|
||||||
|
+ "\"userMessageGlobalisationCode\":\"error.msg.feature.disabled\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-14
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
|
|||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DELETE;
|
import jakarta.ws.rs.DELETE;
|
||||||
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
||||||
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
|
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
|
||||||
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
|
||||||
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
|
*
|
||||||
* key in the first place is not something to hand to just anyone.
|
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
|
||||||
|
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
|
||||||
|
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
|
||||||
|
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
|
||||||
|
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
|
||||||
|
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
|
||||||
|
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
|
||||||
|
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
|
||||||
|
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
|
||||||
|
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
|
||||||
|
* whoever already has direct database access -- never over HTTP.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/apiclients")
|
@Path("/v1/apiclients")
|
||||||
@Component
|
@Component
|
||||||
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final ApiClientCredentialService credentialService;
|
private final ApiClientCredentialService credentialService;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
|
||||||
@POST
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String create(final String body) {
|
public String create(final String body) {
|
||||||
final AppUser user = requireSuperUser();
|
requireSuperUser();
|
||||||
final JsonElement root = fromJsonHelper.parse(body);
|
throw new WebApplicationException(Response.status(403)
|
||||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
|
||||||
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
|
||||||
|
.type(MediaType.APPLICATION_JSON).build());
|
||||||
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
|
||||||
return gson.toJson(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
+12
-15
@@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT;
|
|||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.command.core.CommandPipeline;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
|
import org.apache.fineract.organisation.workingdays.data.WorkingDaysData;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
|
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
|
|
||||||
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
|
import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -50,7 +50,8 @@ public class WorkingDaysApiResource {
|
|||||||
|
|
||||||
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
|
private final WorkingDaysReadPlatformService workingDaysReadPlatformService;
|
||||||
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
|
private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator;
|
||||||
private final CommandPipeline commandPipeline;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<WorkingDaysUpdateRequest> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -65,17 +66,13 @@ public class WorkingDaysApiResource {
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
|
@Operation(summary = "Update a Working Day", description = "Mandatory Fields\n"
|
||||||
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
|
+ "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale")
|
||||||
public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) {
|
public String update(@Valid final WorkingDaysUpdateRequest request) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays()
|
||||||
|
.withJson(toApiJsonSerializer.serialize(request)).build();
|
||||||
|
|
||||||
final var command = new WorkingDaysUpdateCommand();
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
|
||||||
command.setCommandId(System.currentTimeMillis());
|
return toApiJsonSerializer.serialize(result);
|
||||||
command.setCreatedAt(Instant.now());
|
|
||||||
command.setPayload(request);
|
|
||||||
|
|
||||||
final Supplier<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
|
|
||||||
|
|
||||||
return response.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
|
|||||||
+29
-21
@@ -18,38 +18,46 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.organisation.workingdays.handler;
|
package org.apache.fineract.organisation.workingdays.handler;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import org.apache.fineract.command.core.Command;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.command.core.CommandHandler;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest;
|
||||||
import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse;
|
|
||||||
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
|
import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
@Service
|
||||||
@Component
|
@CommandType(entity = "WORKINGDAYS", action = "UPDATE")
|
||||||
@RequiredArgsConstructor
|
public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler {
|
||||||
public class UpdateWorkingDaysCommandHandler implements CommandHandler<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
|
|
||||||
|
|
||||||
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
|
private final WorkingDaysWritePlatformService workingDaysWritePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) {
|
||||||
|
this.workingDaysWritePlatformService = workingDaysWritePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public WorkingDaysUpdateResponse handle(Command<WorkingDaysUpdateRequest> command) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
WorkingDaysUpdateRequest request = command.getPayload();
|
|
||||||
Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
|
|
||||||
if (changes == null) {
|
|
||||||
changes = Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes)
|
final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder()
|
||||||
.recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType())
|
.recurrence(command.stringValueOfParameterNamed("recurrence"))
|
||||||
.extendTermForDailyRepayments(request.getExtendTermForDailyRepayments())
|
.repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType"))
|
||||||
.extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build();
|
.extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments"))
|
||||||
|
.extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
final Map<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.with(changes) //
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.account.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A book debit: money leaves a savings or current account by book entry, not as cash.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...).
|
||||||
|
* Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry --
|
||||||
|
* cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no
|
||||||
|
* report shows.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance
|
||||||
|
* (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's
|
||||||
|
* transaction, so a later failure rolls the debit back with everything else.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccountBookDebitService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
|
|
||||||
|
/** Debits a savings account; returns the m_savings_account_transaction id. */
|
||||||
|
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||||
|
final Long userId) {
|
||||||
|
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
|
||||||
|
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
|
||||||
|
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||||
|
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
|
||||||
|
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
|
||||||
|
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
|
||||||
|
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
|
||||||
|
new String[] { "id" });
|
||||||
|
ps.setLong(1, accountId);
|
||||||
|
ps.setLong(2, officeId);
|
||||||
|
ps.setDate(3, sqlDate);
|
||||||
|
ps.setBigDecimal(4, amount);
|
||||||
|
ps.setDate(5, sqlDate);
|
||||||
|
ps.setString(6, refNo());
|
||||||
|
ps.setBigDecimal(7, balance.subtract(amount));
|
||||||
|
ps.setLong(8, userId);
|
||||||
|
ps.setLong(9, userId);
|
||||||
|
ps.setObject(10, now);
|
||||||
|
ps.setObject(11, now);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, "
|
||||||
|
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
|
||||||
|
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debits a current account; returns the m_current_account_transaction id. */
|
||||||
|
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||||
|
final Long userId, final String note) {
|
||||||
|
final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount);
|
||||||
|
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
|
||||||
|
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||||
|
final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note);
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction "
|
||||||
|
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, "
|
||||||
|
+ "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
|
||||||
|
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" });
|
||||||
|
ps.setLong(1, accountId);
|
||||||
|
ps.setLong(2, officeId);
|
||||||
|
ps.setDate(3, sqlDate);
|
||||||
|
ps.setBigDecimal(4, amount);
|
||||||
|
ps.setString(5, refNo());
|
||||||
|
ps.setDate(6, sqlDate);
|
||||||
|
ps.setBigDecimal(7, balance.subtract(amount));
|
||||||
|
ps.setString(8, remarks);
|
||||||
|
ps.setLong(9, userId);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, "
|
||||||
|
+ "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
|
||||||
|
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
|
||||||
|
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.",
|
||||||
|
accountId);
|
||||||
|
}
|
||||||
|
final int status = ((Number) rows.get(0).get("status_enum")).intValue();
|
||||||
|
if (status != 300) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active",
|
||||||
|
kind + " account " + accountId + " is not active.", accountId);
|
||||||
|
}
|
||||||
|
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
|
||||||
|
: (BigDecimal) rows.get(0).get("account_balance_derived");
|
||||||
|
final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO
|
||||||
|
: (BigDecimal) rows.get(0).get("on_hold_funds_derived");
|
||||||
|
final BigDecimal available = balance.subtract(onHold);
|
||||||
|
if (available.compareTo(amount) < 0) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
|
||||||
|
"Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available
|
||||||
|
+ ", Requested: " + amount,
|
||||||
|
accountId);
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String refNo() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-21
@@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
|||||||
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
|
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
|
||||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||||
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
|
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
|
||||||
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
private final CurrentAccountRepository currentAccountRepository;
|
private final CurrentAccountRepository currentAccountRepository;
|
||||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -472,7 +474,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
|
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
|
||||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
|
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
|
||||||
toSavingsAccount.productId(), toSavingsAccount.getId());
|
toSavingsAccount.productId(), false, toSavingsAccount.getId());
|
||||||
|
|
||||||
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
|
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
|
||||||
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
|
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
|
||||||
@@ -517,7 +519,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||||
accountTransferDTO.getTransactionDate(),
|
accountTransferDTO.getTransactionDate(),
|
||||||
fromSavingsAccount.productId(), fromSavingsAccount.getId());
|
fromSavingsAccount.productId(), false, fromSavingsAccount.getId());
|
||||||
|
|
||||||
if (accountTransferDetails == null) {
|
if (accountTransferDetails == null) {
|
||||||
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
|
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
|
||||||
@@ -545,10 +547,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||||
accountTransferDTO.getTransactionDate(),
|
accountTransferDTO.getTransactionDate(),
|
||||||
toCurrentAccount.getProductId(), toCurrentAccount.getId());
|
toCurrentAccount.getProductId(), true, toCurrentAccount.getId());
|
||||||
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
|
||||||
accountTransferDTO.getTransactionDate(),
|
accountTransferDTO.getTransactionDate(),
|
||||||
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
|
fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId());
|
||||||
|
|
||||||
if (accountTransferDetails == null) {
|
if (accountTransferDetails == null) {
|
||||||
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
|
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
|
||||||
@@ -681,6 +683,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
*/
|
*/
|
||||||
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
|
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
|
||||||
final LocalDate txnDate) {
|
final LocalDate txnDate) {
|
||||||
|
// Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is.
|
||||||
|
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||||
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
||||||
final BigDecimal available = balance.subtract(onHold);
|
final BigDecimal available = balance.subtract(onHold);
|
||||||
@@ -699,10 +703,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
currentAccountRepository.save(account);
|
currentAccountRepository.save(account);
|
||||||
if (account.getProductId() != null && txn.getId() != null) {
|
if (account.getProductId() != null && txn.getId() != null) {
|
||||||
final String currentTxnId = "CA" + txn.getId();
|
final String currentTxnId = "CA" + txn.getId();
|
||||||
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
|
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||||
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||||
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
|
account.getId(), currentTxnId, txnDate, amount);
|
||||||
account.getId(), currentTxnId, txnDate, amount, false);
|
|
||||||
}
|
}
|
||||||
return txn;
|
return txn;
|
||||||
}
|
}
|
||||||
@@ -712,7 +715,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
||||||
*/
|
*/
|
||||||
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
||||||
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
|
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||||
|
final Long counterpartEntityId) {
|
||||||
|
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||||
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
|
||||||
final BigDecimal available = balance.subtract(onHold);
|
final BigDecimal available = balance.subtract(onHold);
|
||||||
@@ -730,7 +735,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
||||||
currentAccountRepository.save(account);
|
currentAccountRepository.save(account);
|
||||||
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
||||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
|
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||||
|
counterpartEntityId);
|
||||||
return txn;
|
return txn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +744,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
* Deposit amount into a current account and save the transaction.
|
* Deposit amount into a current account and save the transaction.
|
||||||
*/
|
*/
|
||||||
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
||||||
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
|
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||||
|
final Long counterpartEntityId) {
|
||||||
|
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||||
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
|
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
|
||||||
final BigDecimal newBalance = balance.add(amount);
|
final BigDecimal newBalance = balance.add(amount);
|
||||||
@@ -749,7 +757,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
||||||
currentAccountRepository.save(account);
|
currentAccountRepository.save(account);
|
||||||
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
||||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
|
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||||
|
counterpartEntityId);
|
||||||
return txn;
|
return txn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +792,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
*/
|
*/
|
||||||
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
|
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
|
||||||
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
|
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
|
||||||
final Long counterpartProductId, final Long counterpartEntityId) {
|
final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) {
|
||||||
if (account.getProductId() == null || txn.getId() == null) {
|
if (account.getProductId() == null || txn.getId() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -794,17 +803,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
final String currentTxnId = "CA" + txn.getId();
|
final String currentTxnId = "CA" + txn.getId();
|
||||||
if (isWithdrawal) {
|
if (isWithdrawal) {
|
||||||
// DR: current account GL CR: counterpart (savings/current) GL
|
// DR: current account GL CR: counterpart (savings/current) GL
|
||||||
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
|
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||||
account.getOffice(), account.getCurrencyCode(),
|
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||||
account.getProductId(), account.getId(),
|
|
||||||
counterpartProductId, counterpartEntityId,
|
|
||||||
currentTxnId, txnDate, amount);
|
currentTxnId, txnDate, amount);
|
||||||
} else {
|
} else {
|
||||||
// DR: counterpart (savings/current) GL CR: current account GL
|
// DR: counterpart (savings/current) GL CR: current account GL
|
||||||
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
|
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||||
account.getOffice(), account.getCurrencyCode(),
|
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||||
counterpartProductId, counterpartEntityId,
|
|
||||||
account.getProductId(), account.getId(),
|
|
||||||
currentTxnId, txnDate, amount);
|
currentTxnId, txnDate, amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-140
@@ -1,140 +1,141 @@
|
|||||||
/**
|
/**
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
* or more contributor license agreements. See the NOTICE file
|
* or more contributor license agreements. See the NOTICE file
|
||||||
* distributed with this work for additional information
|
* distributed with this work for additional information
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
* to you under the Apache License, Version 2.0 (the
|
* to you under the Apache License, Version 2.0 (the
|
||||||
* "License"); you may not use this file except in compliance
|
* "License"); you may not use this file except in compliance
|
||||||
* with the License. You may obtain a copy of the License at
|
* with the License. You may obtain a copy of the License at
|
||||||
*
|
*
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
*
|
*
|
||||||
* Unless required by applicable law or agreed to in writing,
|
* Unless required by applicable law or agreed to in writing,
|
||||||
* software distributed under the License is distributed on an
|
* software distributed under the License is distributed on an
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
* KIND, either express or implied. See the License for the
|
* KIND, either express or implied. See the License for the
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.account.starter;
|
package org.apache.fineract.portfolio.account.starter;
|
||||||
|
|
||||||
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
|
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
|
||||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||||
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
|
import org.apache.fineract.infrastructure.core.service.PaginationHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
|
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
|
||||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||||
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
|
import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
|
||||||
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
|
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
|
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
|
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
|
||||||
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
|
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
|
||||||
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
|
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
|
||||||
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
|
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
|
||||||
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
|
||||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
|
import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler;
|
||||||
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
|
import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository;
|
||||||
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
|
import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
|
import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
|
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
|
import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
|
import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
|
||||||
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
|
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@Configuration
|
|
||||||
public class AccountConfiguration {
|
@Configuration
|
||||||
|
public class AccountConfiguration {
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
|
@Bean
|
||||||
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
|
@ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class)
|
||||||
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
|
public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) {
|
||||||
}
|
return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
|
@Bean
|
||||||
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
|
@ConditionalOnMissingBean(AccountTransfersReadPlatformService.class)
|
||||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
|
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||||
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
|
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator,
|
||||||
SqlValidator sqlValidator) {
|
DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper,
|
||||||
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
SqlValidator sqlValidator) {
|
||||||
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
|
return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||||
}
|
portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
|
@Bean
|
||||||
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
|
@ConditionalOnMissingBean(AccountTransfersWritePlatformService.class)
|
||||||
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
|
public AccountTransfersWritePlatformService accountTransfersWritePlatformService(
|
||||||
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
|
AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler,
|
||||||
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
|
AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler,
|
||||||
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
|
SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler,
|
||||||
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
|
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
|
||||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
|
||||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||||
AccountingProcessorHelper accountingProcessorHelper) {
|
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
|
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||||
}
|
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
|
@Bean
|
||||||
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
|
@ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class)
|
||||||
DatabaseSpecificSQLGenerator sqlGenerator) {
|
public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||||
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
|
DatabaseSpecificSQLGenerator sqlGenerator) {
|
||||||
}
|
return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
|
@Bean
|
||||||
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
|
@ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class)
|
||||||
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||||
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
|
ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||||
}
|
return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
|
@Bean
|
||||||
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
|
@ConditionalOnMissingBean(StandingInstructionReadPlatformService.class)
|
||||||
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate,
|
||||||
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
|
ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService,
|
||||||
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
|
PortfolioAccountReadPlatformService portfolioAccountReadPlatformService,
|
||||||
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator,
|
||||||
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) {
|
||||||
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
|
return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService,
|
||||||
}
|
portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper);
|
||||||
|
}
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
|
@Bean
|
||||||
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
|
@ConditionalOnMissingBean(StandingInstructionWritePlatformService.class)
|
||||||
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
|
public StandingInstructionWritePlatformService standingInstructionWritePlatformService(
|
||||||
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
|
StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler,
|
||||||
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
|
AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) {
|
||||||
accountTransferDetailRepository, standingInstructionRepository);
|
return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler,
|
||||||
}
|
accountTransferDetailRepository, standingInstructionRepository);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -61,6 +61,8 @@ public interface ShareAccountApiConstants {
|
|||||||
String externalid_paramname = "externalId";
|
String externalid_paramname = "externalId";
|
||||||
|
|
||||||
String resolutionnumber_paramname = "resolutionNumber";
|
String resolutionnumber_paramname = "resolutionNumber";
|
||||||
|
/** Date of the board meeting that passed the resolution. */
|
||||||
|
String resolutiondate_paramname = "resolutionDate";
|
||||||
|
|
||||||
String currency_paramname = "currencyCode";
|
String currency_paramname = "currencyCode";
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ public interface ShareAccountApiConstants {
|
|||||||
|
|
||||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
resolutionnumber_paramname, resolutiondate_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||||
|
|||||||
+10
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.api;
|
package org.apache.fineract.portfolio.aml.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
@@ -30,4 +31,13 @@ public class AlertReviewRequest {
|
|||||||
|
|
||||||
private String disposition;
|
private String disposition;
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules being decided, when the officer is deciding some of them rather than the whole alert.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level
|
||||||
|
* Whitelist and Suspicious buttons send and what this endpoint has always done.
|
||||||
|
*/
|
||||||
|
private List<String> ruleCodes;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -71,7 +71,13 @@ public class AmlAlertApiResource {
|
|||||||
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
|
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
|
||||||
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
|
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||||
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
|
final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition());
|
||||||
|
if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) {
|
||||||
|
// Deciding named rules within the alert. The alert's own status follows from what is left undecided, so
|
||||||
|
// it is not set here.
|
||||||
|
this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST",
|
||||||
|
request.getNotes());
|
||||||
|
} else if (escalating) {
|
||||||
this.writePlatformService.escalate(alertId, request.getNotes());
|
this.writePlatformService.escalate(alertId, request.getNotes());
|
||||||
} else {
|
} else {
|
||||||
this.writePlatformService.whitelist(alertId, request.getNotes());
|
this.writePlatformService.whitelist(alertId, request.getNotes());
|
||||||
|
|||||||
+25
@@ -25,6 +25,7 @@ import jakarta.ws.rs.GET;
|
|||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -32,6 +33,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -47,6 +49,7 @@ public class AmlCtrReportApiResource {
|
|||||||
private final AmlCtrReportReadPlatformService readPlatformService;
|
private final AmlCtrReportReadPlatformService readPlatformService;
|
||||||
private final AmlCtrReportGenerationService generationService;
|
private final AmlCtrReportGenerationService generationService;
|
||||||
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -58,6 +61,28 @@ public class AmlCtrReportApiResource {
|
|||||||
return this.toApiJsonSerializer.serialize(reports);
|
return this.toApiJsonSerializer.serialize(reports);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("lines")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "The reportable lines behind the generated CTRs",
|
||||||
|
description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.")
|
||||||
|
public String retrieveLines(@QueryParam("reportId") final Long reportId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("pending-lines")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reportable cash transactions not yet in any generated CTR",
|
||||||
|
description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.")
|
||||||
|
public String retrievePendingLines() {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines());
|
||||||
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("generate")
|
@Path("generate")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving a customer's KYC risk category, under maker-checker.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Raised from the Risk Review Report, which already works out which customers are rated below what
|
||||||
|
* their behaviour warrants. The rating moves only when a checker approves, and the change is written
|
||||||
|
* into the customer's risk history with the reason given.
|
||||||
|
*/
|
||||||
|
@Path("/v1/aml/risk-change-requests")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Client Risk Category Change",
|
||||||
|
description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientRiskChangeApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientRiskChangeRequestService service;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which
|
||||||
|
* Fineract's global handler does not map - left uncaught they reach the client as an opaque 500
|
||||||
|
* with no message. Wrapped so the reviewer is told what actually stopped them.
|
||||||
|
*/
|
||||||
|
private <T> T safely(final Supplier<T> action) {
|
||||||
|
try {
|
||||||
|
return action.get();
|
||||||
|
} catch (final IllegalArgumentException | IllegalStateException e) {
|
||||||
|
throw apiError(400, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("message", message);
|
||||||
|
body.addProperty("defaultUserMessage", message);
|
||||||
|
body.addProperty("httpStatus", httpStatus);
|
||||||
|
final JsonArray errors = new JsonArray();
|
||||||
|
final JsonObject err = new JsonObject();
|
||||||
|
err.addProperty("defaultUserMessage", message);
|
||||||
|
errors.add(err);
|
||||||
|
body.add("errors", errors);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List risk category change requests",
|
||||||
|
description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.")
|
||||||
|
public String retrieveAll(@QueryParam("status") final String status) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.gson.toJson(this.service.retrieveAll(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Request a change to a customer's risk category",
|
||||||
|
description = "Maker step. Nothing moves until a checker approves.")
|
||||||
|
public CommandProcessingResult request(final RiskChangeRequest request) {
|
||||||
|
return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve a risk category change",
|
||||||
|
description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.")
|
||||||
|
public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) {
|
||||||
|
return safely(() -> this.service.approve(requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.")
|
||||||
|
public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) {
|
||||||
|
return safely(() -> this.service.reject(requestId, request.getRejectionReason()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */
|
||||||
|
public static final class RiskChangeRequest {
|
||||||
|
|
||||||
|
private Long clientId;
|
||||||
|
private Integer requestedCategory;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public Long getClientId() {
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClientId(Long clientId) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRequestedCategory() {
|
||||||
|
return requestedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestedCategory(Integer requestedCategory) {
|
||||||
|
this.requestedCategory = requestedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class RiskChangeRejectRequest {
|
||||||
|
|
||||||
|
private String rejectionReason;
|
||||||
|
|
||||||
|
public String getRejectionReason() {
|
||||||
|
return rejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRejectionReason(String rejectionReason) {
|
||||||
|
this.rejectionReason = rejectionReason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -30,6 +30,16 @@ import java.time.LocalDateTime;
|
|||||||
*/
|
*/
|
||||||
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
|
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
|
||||||
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
||||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
|
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory,
|
||||||
|
/** Other rules the same transaction matched, comma-separated. Null when only one fired. */
|
||||||
|
String alsoMatchedRules,
|
||||||
|
/** Worst severity across every rule the transaction matched, not only the governing one. */
|
||||||
|
Integer highestSeverity,
|
||||||
|
/**
|
||||||
|
* Every rule the transaction matched, governing one first, each with its description and severity. The
|
||||||
|
* comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands
|
||||||
|
* to show, because a bare rule code does not tell an officer what was detected or how serious it is.
|
||||||
|
*/
|
||||||
|
java.util.List<AmlMatchedRuleData> matchedRules)
|
||||||
implements Serializable {
|
implements Serializable {
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather
|
||||||
|
* than only the ids the line is stored under.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The stored line keeps a client id and a transaction id; a filing needs the account, the customer,
|
||||||
|
* the branch and the date those ids stand for. They are joined here so the screen and the CSV are
|
||||||
|
* built from the report the bank generated, not from a second calculation that could disagree with
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A connected-group line has no single transaction behind it - it is a month's worth of smaller
|
||||||
|
* cash movements by one customer that together cross the threshold - so its transaction id, account
|
||||||
|
* and date are null, and {@code connectedGroup} says why.
|
||||||
|
*/
|
||||||
|
public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId,
|
||||||
|
Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction,
|
||||||
|
BigDecimal amount, boolean connectedGroup,
|
||||||
|
/**
|
||||||
|
* Account facts the Account_Detail file reports. They were being filled in with a constant
|
||||||
|
* opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement
|
||||||
|
* about the customer's account that nobody had checked.
|
||||||
|
*/
|
||||||
|
LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus,
|
||||||
|
/** Customer identifiers the Account_Person_Relation file reports. */
|
||||||
|
String ckycNumber, String clientExternalId, boolean individual) implements Serializable {}
|
||||||
+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.aml.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which way the money moved, for rules written about one direction only.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised
|
||||||
|
* "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one
|
||||||
|
* transaction were of that kind, and an analyst reading them cannot tell which one is real.
|
||||||
|
*/
|
||||||
|
public enum AmlDirection {
|
||||||
|
|
||||||
|
/** Money into the customer's account: a deposit, or a loan disbursal. */
|
||||||
|
CREDIT,
|
||||||
|
/** Money out of it: a withdrawal, or a loan repayment. */
|
||||||
|
DEBIT,
|
||||||
|
/** Not established — the rule's own direction filter, if it has one, then does not apply. */
|
||||||
|
UNKNOWN;
|
||||||
|
|
||||||
|
public boolean isCredit() {
|
||||||
|
return this == CREDIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDebit() {
|
||||||
|
return this == DEBIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rule a transaction matched, as the alert screen needs to show it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An
|
||||||
|
* officer deciding what to do with a transaction has to know what each of those codes means and how serious it is -
|
||||||
|
* "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision.
|
||||||
|
*
|
||||||
|
* @param governing
|
||||||
|
* whether this is the rule the alert was raised under. One transaction raises one alert, under whichever
|
||||||
|
* rule most warrants attention, so exactly one entry in the list carries this.
|
||||||
|
* @param riskCategory
|
||||||
|
* 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns
|
||||||
|
* rather than graded suspicions, so they carry no band.
|
||||||
|
*/
|
||||||
|
public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing,
|
||||||
|
/** WHITELIST, ESCALATE, or null while this rule is still open. */
|
||||||
|
String disposition, String reviewedByUsername, String reviewNotes,
|
||||||
|
/** True where the officer named this rule; false where it was closed alongside one they named. */
|
||||||
|
boolean directlyDecided) implements Serializable {
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of customer a rule is written about, for rules whose subject is the customer rather than
|
||||||
|
* the transaction.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity
|
||||||
|
* in their own title, but nothing checked it: each was matched on amount alone and so fired on every
|
||||||
|
* ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious
|
||||||
|
* reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts,
|
||||||
|
* and the one real politically exposed person is then missed among them.
|
||||||
|
*/
|
||||||
|
public enum AmlSubjectScope {
|
||||||
|
|
||||||
|
/** Politically exposed person, their relative or close associate. */
|
||||||
|
PEP,
|
||||||
|
|
||||||
|
/** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */
|
||||||
|
CHARITABLE_ORG,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dealers in precious metal and stones and the other businesses RBI names as higher risk,
|
||||||
|
* where a large transaction is worth reporting that would be routine for anyone else.
|
||||||
|
*/
|
||||||
|
HIGH_RISK_BUSINESS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recently opened account. A large transaction through an account that has barely been used
|
||||||
|
* is the classic mule pattern; the same transaction on a decade-old account is not remarkable.
|
||||||
|
*/
|
||||||
|
NEW_ACCOUNT;
|
||||||
|
|
||||||
|
public static AmlSubjectScope fromRule(final String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return AmlSubjectScope.valueOf(value.trim().toUpperCase());
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
// An unrecognised scope must not silently widen the rule back to everyone, so the
|
||||||
|
// caller treats null-from-blank and this case differently.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -19,7 +19,9 @@
|
|||||||
package org.apache.fineract.portfolio.aml.data;
|
package org.apache.fineract.portfolio.aml.data;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
|
||||||
@@ -45,9 +47,43 @@ public final class AmlTransactionContext {
|
|||||||
* rules are simply skipped for this context rather than guessed at.
|
* rules are simply skipped for this context rather than guessed at.
|
||||||
*/
|
*/
|
||||||
private final BigDecimal windowAggregateAmount;
|
private final BigDecimal windowAggregateAmount;
|
||||||
|
/**
|
||||||
|
* Which way the money moved. A rule written about withdrawals must not fire on a deposit, and
|
||||||
|
* "repayment of loan in cash" is not the same event as a disbursal.
|
||||||
|
*/
|
||||||
|
private final AmlDirection direction;
|
||||||
|
/**
|
||||||
|
* How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a
|
||||||
|
* pattern of MANY small transactions, so the count is what separates it from one large one:
|
||||||
|
* a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads.
|
||||||
|
*/
|
||||||
|
private final int windowTransactionCount;
|
||||||
|
/**
|
||||||
|
* Every qualifying amount in the window, so a rule can count only what falls inside its own
|
||||||
|
* band. A single count cannot serve every rule: this client's window held four deposits — three
|
||||||
|
* small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule
|
||||||
|
* whose band none of the four sat in. Structuring is several transactions of a similar size,
|
||||||
|
* and only the amounts themselves can say whether that happened.
|
||||||
|
*/
|
||||||
|
private final List<BigDecimal> windowAmounts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the account this transaction moved through was opened. The "large transaction on a new
|
||||||
|
* account" rule is about the account rather than the customer, and an old customer can open a
|
||||||
|
* new account. Null where the caller could not establish it, in which case the customer's own
|
||||||
|
* activation date stands in.
|
||||||
|
*/
|
||||||
|
private final LocalDate subjectOpenedOn;
|
||||||
|
|
||||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
||||||
|
this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN,
|
||||||
|
0, List.of(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||||
|
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction,
|
||||||
|
int windowTransactionCount, List<BigDecimal> windowAmounts, LocalDate subjectOpenedOn) {
|
||||||
this.transactionType = transactionType;
|
this.transactionType = transactionType;
|
||||||
this.transactionId = transactionId;
|
this.transactionId = transactionId;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
@@ -56,6 +92,26 @@ public final class AmlTransactionContext {
|
|||||||
this.occurredOn = occurredOn;
|
this.occurredOn = occurredOn;
|
||||||
this.cash = cash;
|
this.cash = cash;
|
||||||
this.windowAggregateAmount = windowAggregateAmount;
|
this.windowAggregateAmount = windowAggregateAmount;
|
||||||
|
this.direction = direction;
|
||||||
|
this.windowTransactionCount = windowTransactionCount;
|
||||||
|
this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts);
|
||||||
|
this.subjectOpenedOn = subjectOpenedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlDirection getDirection() {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getWindowTransactionCount() {
|
||||||
|
return windowTransactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BigDecimal> getWindowAmounts() {
|
||||||
|
return windowAmounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getSubjectOpenedOn() {
|
||||||
|
return subjectOpenedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AmlTransactionType getTransactionType() {
|
public AmlTransactionType getTransactionType() {
|
||||||
|
|||||||
+23
-6
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
|
|||||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
||||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
||||||
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||||
@@ -113,12 +114,22 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
|||||||
}
|
}
|
||||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||||
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
||||||
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
|
// Same side of the ledger as the transaction being observed: a deposit is not part of a
|
||||||
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
// withdrawal pattern, so the two are aggregated separately.
|
||||||
|
final List<SavingsAccountTransaction> sameDirection = recent.stream()
|
||||||
|
.filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList();
|
||||||
|
// Each amount is carried through, not just the total: a rule has to be able to count what
|
||||||
|
// falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or
|
||||||
|
// more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be
|
||||||
|
// reported as structuring.
|
||||||
|
final List<BigDecimal> windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList();
|
||||||
|
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
||||||
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
||||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate,
|
||||||
|
depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts,
|
||||||
|
transaction.getSavingsAccount().getActivationDate());
|
||||||
try {
|
try {
|
||||||
ruleEvaluationService.evaluate(context);
|
ruleEvaluationService.evaluate(context);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
@@ -134,12 +145,18 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
|||||||
}
|
}
|
||||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||||
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
||||||
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
|
final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
|
||||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
final List<BigDecimal> windowAmounts = repayments.stream()
|
||||||
|
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList();
|
||||||
|
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
// A repayment takes money from the customer; a disbursal gives it to them. Rules such as
|
||||||
|
// "repayment of loan in cash" are about the first and must not fire on the second.
|
||||||
|
final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT;
|
||||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
||||||
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
||||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction,
|
||||||
|
repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate());
|
||||||
try {
|
try {
|
||||||
ruleEvaluationService.evaluate(context);
|
ruleEvaluationService.evaluate(context);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
|
|||||||
+125
-8
@@ -18,15 +18,25 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
|
|||||||
|
|
||||||
private final AmlAlertRepository repository;
|
private final AmlAlertRepository repository;
|
||||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||||
|
private final AmlAlertRuleRepository alertRuleRepository;
|
||||||
|
private final AmlTransactionRuleRepository transactionRuleRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||||
|
// Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every
|
||||||
|
// lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so
|
||||||
|
// without this the screen fails outright at the very moment it has nothing to report.
|
||||||
|
if (alerts.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
||||||
// lookup inside the map step would re-read the same catalog rows repeatedly.
|
// lookup inside the map step would re-read the same catalog rows repeatedly.
|
||||||
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
|
// Same reasoning for the also-matched rules, which are held on the alert as bare codes: one
|
||||||
|
// read for every code on the page, not one per alert.
|
||||||
|
// The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole
|
||||||
|
// page at once rather than per alert.
|
||||||
|
final Map<Long, List<AmlAlertRule>> findingsByAlertId = this.alertRuleRepository
|
||||||
|
.findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream()
|
||||||
|
.collect(Collectors.groupingBy(f -> f.getAlert().getId()));
|
||||||
|
final Map<String, AmlTransactionRule> rulesByCode = rulesByCodeFor(alerts, findingsByAlertId);
|
||||||
|
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values());
|
||||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
||||||
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
|
.map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
|
/**
|
||||||
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
|
* Every rule named anywhere on this page, keyed on its code.
|
||||||
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
|
*
|
||||||
.toList();
|
* <p>
|
||||||
|
* Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the
|
||||||
|
* description behind it.
|
||||||
|
*/
|
||||||
|
private Map<String, AmlTransactionRule> rulesByCodeFor(final List<AmlAlert> alerts,
|
||||||
|
final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||||
|
final Set<String> codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank())
|
||||||
|
.flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty())
|
||||||
|
.collect(Collectors.toCollection(java.util.HashSet::new));
|
||||||
|
findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add);
|
||||||
|
alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add);
|
||||||
|
if (codes.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
final Map<String, AmlTransactionRule> byCode = new HashMap<>();
|
||||||
|
this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule));
|
||||||
|
return byCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every rule the transaction matched, governing one first, each carrying its description and severity.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the
|
||||||
|
* officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was
|
||||||
|
* raised - keeps its code as the description, because the alert is a record of what happened and editing the rule
|
||||||
|
* set afterwards does not change that.
|
||||||
|
*/
|
||||||
|
private List<AmlMatchedRuleData> matchedRulesOf(final AmlAlert alert, final Map<Long, Integer> riskByRiskRuleId,
|
||||||
|
final Map<String, AmlTransactionRule> rulesByCode, final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||||
|
final List<AmlAlertRule> findings = findingsByAlertId.get(alert.getId());
|
||||||
|
if (findings != null && !findings.isEmpty()) {
|
||||||
|
// Governing rule first, then by code, so the pane does not reorder itself between reads.
|
||||||
|
return findings.stream()
|
||||||
|
.sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode))
|
||||||
|
.map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(),
|
||||||
|
f.isGoverning(), f.getDisposition(),
|
||||||
|
f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(),
|
||||||
|
f.isDirectlyDecided()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat
|
||||||
|
// fields so the pane still shows what fired, with no per-rule disposition to report.
|
||||||
|
final List<AmlMatchedRuleData> matched = new ArrayList<>();
|
||||||
|
final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId();
|
||||||
|
matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(),
|
||||||
|
governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false));
|
||||||
|
|
||||||
|
final String also = alert.getAlsoMatchedRules();
|
||||||
|
if (also == null || also.isBlank()) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
for (final String raw : also.split(",")) {
|
||||||
|
final String code = raw.trim();
|
||||||
|
if (code.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final AmlTransactionRule rule = rulesByCode.get(code);
|
||||||
|
final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId();
|
||||||
|
matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode),
|
||||||
|
sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false));
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule's description, or its code when the rule itself is gone.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code
|
||||||
|
* stands in rather than the row disappearing.
|
||||||
|
*/
|
||||||
|
private String describe(final String ruleCode, final Map<String, AmlTransactionRule> rulesByCode) {
|
||||||
|
final AmlTransactionRule rule = rulesByCode.get(ruleCode);
|
||||||
|
return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity of each originating catalog rule, keyed on m_client_risk_rule.id.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every
|
||||||
|
* also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show.
|
||||||
|
*/
|
||||||
|
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts, final Collection<AmlTransactionRule> alsoMatched) {
|
||||||
|
final List<Long> sourceIds = Stream
|
||||||
|
.concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()),
|
||||||
|
alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId))
|
||||||
|
.filter(id -> id != null).distinct().toList();
|
||||||
final Map<Long, Integer> byId = new HashMap<>();
|
final Map<Long, Integer> byId = new HashMap<>();
|
||||||
if (sourceIds.isEmpty()) {
|
if (sourceIds.isEmpty()) {
|
||||||
return byId;
|
return byId;
|
||||||
@@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
|
|||||||
return byId;
|
return byId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
|
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId, Map<String, AmlTransactionRule> rulesByCode,
|
||||||
|
Map<Long, List<AmlAlertRule>> findingsByAlertId) {
|
||||||
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
|
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
|
||||||
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
|
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
|
||||||
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
||||||
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
||||||
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
||||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
||||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
|
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory,
|
||||||
|
alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||||
|
|
||||||
@@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService {
|
|||||||
AmlAlert whitelist(Long alertId, String notes);
|
AmlAlert whitelist(Long alertId, String notes);
|
||||||
|
|
||||||
AmlStrCase escalate(Long alertId, String notes);
|
AmlStrCase escalate(Long alertId, String notes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalates several alerts into a single STR case.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert
|
||||||
|
* separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a
|
||||||
|
* pattern that only means anything taken together - and a customer reviewed again a month later would add several
|
||||||
|
* more. An STR to FIU-IND reports the activity, so the case has to hold all of it.
|
||||||
|
*
|
||||||
|
* @param alertIds
|
||||||
|
* the alerts the pattern is made of; any already covered by a case keep that case
|
||||||
|
* @return the case covering them, or null when every alert was already covered
|
||||||
|
*/
|
||||||
|
AmlStrCase escalateAsOneCase(List<Long> alertIds, String notes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides individual rules within one alert, leaving the rest open.
|
||||||
|
*
|
||||||
|
* @param ruleCodes
|
||||||
|
* the rules being decided now
|
||||||
|
* @param disposition
|
||||||
|
* WHITELIST or ESCALATE, applied to each of them
|
||||||
|
*/
|
||||||
|
AmlAlert reviewRules(Long alertId, List<String> ruleCodes, String disposition, String notes);
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-2
@@ -19,12 +19,21 @@
|
|||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -34,28 +43,200 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
|
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
|
||||||
|
|
||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final AmlAlertRuleRepository alertRuleRepository;
|
||||||
private final AmlStrCaseRepository strCaseRepository;
|
private final AmlStrCaseRepository strCaseRepository;
|
||||||
|
private final AmlStrCaseAlertRepository strCaseAlertRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AmlAlert whitelist(Long alertId, String notes) {
|
public AmlAlert whitelist(Long alertId, String notes) {
|
||||||
final AmlAlert alert = loadAlert(alertId);
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
alert.whitelist(context.authenticatedUser(), notes);
|
alert.whitelist(context.authenticatedUser(), notes);
|
||||||
|
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
|
||||||
return alertRepository.save(alert);
|
return alertRepository.save(alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AmlStrCase escalate(Long alertId, String notes) {
|
public AmlStrCase escalate(Long alertId, String notes) {
|
||||||
final AmlAlert alert = loadAlert(alertId);
|
// loadForDisposal has already refused an alert that was decided, so this can only be reached on an open one.
|
||||||
|
// An STR is about the transaction, and one transaction gets one case.
|
||||||
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
alert.escalate(context.authenticatedUser(), notes);
|
alert.escalate(context.authenticatedUser(), notes);
|
||||||
|
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
||||||
alertRepository.save(alert);
|
alertRepository.save(alert);
|
||||||
|
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AmlStrCase openStrCase(final AmlAlert alert) {
|
||||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||||
|
// Recorded in the link table as well as on alert_id, so every case describes what it covers the same way -
|
||||||
|
// a single-transaction case covering one alert, a pattern case covering several.
|
||||||
|
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
|
||||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||||
return strCase;
|
return strCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AmlStrCase escalateAsOneCase(final List<Long> alertIds, final String notes) {
|
||||||
|
if (alertIds == null || alertIds.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
AmlStrCase strCase = null;
|
||||||
|
|
||||||
|
for (final Long alertId : alertIds) {
|
||||||
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
|
|
||||||
|
// An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice
|
||||||
|
// under two references, and the older case is the one an officer may already have worked on.
|
||||||
|
final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null);
|
||||||
|
if (existing != null) {
|
||||||
|
if (strCase == null) {
|
||||||
|
strCase = existing.getStrCase();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.escalate(currentUser, notes);
|
||||||
|
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
|
||||||
|
alertRepository.save(alert);
|
||||||
|
|
||||||
|
if (strCase == null) {
|
||||||
|
// Opened from the first alert that needed one, which is the oldest: the case is dated and referenced
|
||||||
|
// from where the pattern starts rather than from whichever alert happened to be escalated last.
|
||||||
|
strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||||
|
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||||
|
}
|
||||||
|
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
|
||||||
|
}
|
||||||
|
|
||||||
|
return strCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a decision against individual rules within one alert.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A transaction can trip six rules where five are explained by the customer's declared business and the sixth is
|
||||||
|
* not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays
|
||||||
|
* open while any rule is undecided, because a case with an open finding is not a closed case.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not
|
||||||
|
* about each rule that described it. Escalating a second rule on an alert already escalated adds the finding
|
||||||
|
* without opening a duplicate case.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
|
||||||
|
final AmlAlert alert = loadForDisposal(alertId);
|
||||||
|
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
|
||||||
|
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
|
||||||
|
|
||||||
|
final Set<String> requested = Set.copyOf(ruleCodes);
|
||||||
|
// A finding already decided is left alone, for the same reason the alert itself is: the decision recorded
|
||||||
|
// against a rule is the bank's answer on it, and a second pass writing over it would leave the file showing
|
||||||
|
// only whatever was sent last. Asked to decide rules that are all already decided, this refuses rather than
|
||||||
|
// silently doing nothing.
|
||||||
|
final List<AmlAlertRule> targeted = findings.stream()
|
||||||
|
.filter(f -> requested.contains(f.getRuleCode()) && !f.isDecided()).toList();
|
||||||
|
if (targeted.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"No open rules matching " + String.join(", ", ruleCodes) + " on alert " + alertId + " — already decided");
|
||||||
|
}
|
||||||
|
for (final AmlAlertRule finding : targeted) {
|
||||||
|
finding.decide(decision, context.authenticatedUser(), notes, true);
|
||||||
|
alertRuleRepository.save(finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read state across every finding, including the ones just written, to decide where the alert now stands.
|
||||||
|
final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated);
|
||||||
|
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
|
||||||
|
|
||||||
|
// Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant
|
||||||
|
// an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely
|
||||||
|
// because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One
|
||||||
|
// suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case
|
||||||
|
// that is already open.
|
||||||
|
if (anyEscalated) {
|
||||||
|
alert.escalate(context.authenticatedUser(), notes);
|
||||||
|
// The findings left open were carried into the escalation, so nothing is left dangling behind a decided
|
||||||
|
// case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were
|
||||||
|
// closed to keep the case consistent, and the record must not claim otherwise.
|
||||||
|
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false);
|
||||||
|
alertRepository.save(alert);
|
||||||
|
if (!wasEscalated) {
|
||||||
|
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||||
|
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||||
|
}
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while
|
||||||
|
// one of its rules is still unexamined.
|
||||||
|
//
|
||||||
|
// Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion -
|
||||||
|
// it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never
|
||||||
|
// offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable,
|
||||||
|
// waiting on a judgement that is not the officer's to make.
|
||||||
|
if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) {
|
||||||
|
return alertRepository.save(alert);
|
||||||
|
}
|
||||||
|
alert.whitelist(context.authenticatedUser(), notes);
|
||||||
|
return alertRepository.save(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries a whole-alert decision down to every finding still open beneath it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings
|
||||||
|
* blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings
|
||||||
|
* already decided individually keep the decision the officer made about them.
|
||||||
|
*/
|
||||||
|
private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes,
|
||||||
|
final boolean directlyDecided) {
|
||||||
|
for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) {
|
||||||
|
if (!finding.isDecided()) {
|
||||||
|
finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided);
|
||||||
|
alertRuleRepository.save(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an alert the current officer is entitled to dispose of.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An alert another officer has open is theirs until they decide it or hand it back. Two officers reaching their
|
||||||
|
* own verdicts on one transaction is the failure this prevents - both writes succeed, and the case ends up
|
||||||
|
* recorded as whatever the second one happened to choose. Refusing here rather than in the screen is what makes
|
||||||
|
* it a rule: the screen hides the buttons, but the endpoint is what enforces it.
|
||||||
|
*/
|
||||||
|
private AmlAlert loadForDisposal(final Long alertId) {
|
||||||
|
final AmlAlert alert = loadAlert(alertId);
|
||||||
|
|
||||||
|
// A disposition is final. Whitelisting or escalating an alert is the bank's recorded answer to a flagged
|
||||||
|
// transaction - an escalation has already opened an STR case and re-rated the customer - and letting a second
|
||||||
|
// officer write over it would leave the file saying whatever was submitted last, with the earlier decision and
|
||||||
|
// the reasoning behind it simply gone. Anything that genuinely has to be revisited is reopened deliberately,
|
||||||
|
// through the STR case, where the change is itself recorded.
|
||||||
|
final AmlAlertStatus status = alert.getStatus();
|
||||||
|
if (AmlAlertStatus.WHITELISTED.equals(status) || AmlAlertStatus.ESCALATED.equals(status)) {
|
||||||
|
throw new IllegalStateException("Alert " + alertId + " was already " + status.name().toLowerCase() + " by "
|
||||||
|
+ (alert.getReviewedBy() == null ? "another officer" : alert.getReviewedBy().getUsername())
|
||||||
|
+ " and cannot be decided again");
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser current = context.authenticatedUser();
|
||||||
|
if (alert.isHeldByAnother(current)) {
|
||||||
|
throw new IllegalStateException("Alert " + alertId + " is being reviewed by "
|
||||||
|
+ alert.getReviewedBy().getUsername() + " and cannot be decided by another officer until they release it");
|
||||||
|
}
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
private AmlAlert loadAlert(Long alertId) {
|
private AmlAlert loadAlert(Long alertId) {
|
||||||
return alertRepository.findById(alertId)
|
return alertRepository.findById(alertId)
|
||||||
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
|
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
|
||||||
|
|||||||
+17
-2
@@ -26,11 +26,13 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||||
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
|
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
|
||||||
@@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService {
|
|||||||
|
|
||||||
public AmlCtrReport generateForMonth(YearMonth month) {
|
public AmlCtrReport generateForMonth(YearMonth month) {
|
||||||
final String reportMonth = month.toString();
|
final String reportMonth = month.toString();
|
||||||
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
|
// A month can be rebuilt while its report is still a draft, and must not be once it has been submitted.
|
||||||
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
|
// Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash
|
||||||
|
// transaction posted after it ran belonged to that month's filing and could never be added to it - the report
|
||||||
|
// was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its
|
||||||
|
// lines with what the month now holds; a submitted report is left exactly as filed.
|
||||||
|
final Optional<AmlCtrReport> existing = ctrReportRepository.findByReportMonth(reportMonth);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
final AmlCtrReport report = existing.get();
|
||||||
|
if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated");
|
||||||
|
}
|
||||||
|
ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId()));
|
||||||
|
ctrReportRepository.delete(report);
|
||||||
|
ctrReportRepository.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
final LocalDate start = month.atDay(1);
|
final LocalDate start = month.atDay(1);
|
||||||
|
|||||||
+18
@@ -20,8 +20,26 @@ package org.apache.fineract.portfolio.aml.service;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||||
|
|
||||||
public interface AmlCtrReportReadPlatformService {
|
public interface AmlCtrReportReadPlatformService {
|
||||||
|
|
||||||
List<AmlCtrReportData> retrieveAll();
|
List<AmlCtrReportData> retrieveAll();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reportable lines, resolved to the account, customer and branch a filing names.
|
||||||
|
*
|
||||||
|
* @param reportId
|
||||||
|
* one month's report, or null for every generated month
|
||||||
|
*/
|
||||||
|
List<AmlCtrReportLineData> retrieveLines(Long reportId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reportable cash transactions that no generated report contains yet.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no
|
||||||
|
* report until the month is regenerated. These are those transactions, so a reportable one is never invisible.
|
||||||
|
*/
|
||||||
|
List<AmlCtrReportLineData> retrievePendingLines();
|
||||||
}
|
}
|
||||||
|
|||||||
+128
@@ -18,13 +18,18 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -33,7 +38,90 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash
|
||||||
|
* moved, so the line says so rather than leaving the reader to infer it from the amount.
|
||||||
|
*/
|
||||||
|
private static final String LINES_SQL = """
|
||||||
|
SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id,
|
||||||
|
l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group,
|
||||||
|
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
||||||
|
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
||||||
|
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
||||||
|
sa.activatedon_date AS account_opened_on,
|
||||||
|
sa.closedon_date AS account_closed_on,
|
||||||
|
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
||||||
|
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
||||||
|
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
||||||
|
WHEN 800 THEN 'MATURED' END AS account_status,
|
||||||
|
k.ckyc_number AS ckyc_number,
|
||||||
|
-- The customer's own reference for the filing: CKYC number if the record carries
|
||||||
|
-- one, else the CIF the bank knows them by. Never blank, because a relation row
|
||||||
|
-- that identifies nobody cannot be reconciled against the customer card.
|
||||||
|
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
||||||
|
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
||||||
|
FROM m_aml_ctr_report_line l
|
||||||
|
JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id
|
||||||
|
JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||||
|
LEFT JOIN m_savings_account_transaction t
|
||||||
|
ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS'
|
||||||
|
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash transactions that a CTR must name but that no generated report contains yet.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs
|
||||||
|
* to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks
|
||||||
|
* to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is
|
||||||
|
* listed here as pending: shaped exactly like a generated line, with no report behind it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and
|
||||||
|
* cannot be decided while the month is still running.
|
||||||
|
*/
|
||||||
|
private static final String PENDING_LINES_SQL = """
|
||||||
|
SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id,
|
||||||
|
to_char(t.transaction_date, 'YYYY-MM') AS report_month,
|
||||||
|
'SAVINGS' AS transaction_type, t.id AS transaction_id,
|
||||||
|
c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group,
|
||||||
|
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
||||||
|
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
||||||
|
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
||||||
|
sa.activatedon_date AS account_opened_on,
|
||||||
|
sa.closedon_date AS account_closed_on,
|
||||||
|
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
||||||
|
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
||||||
|
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
||||||
|
WHEN 800 THEN 'MATURED' END AS account_status,
|
||||||
|
k.ckyc_number AS ckyc_number,
|
||||||
|
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
||||||
|
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||||
|
JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||||
|
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||||
|
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||||
|
WHERE pt.is_cash_payment = true
|
||||||
|
AND t.is_reversed = false
|
||||||
|
AND t.transaction_type_enum IN (1, 2)
|
||||||
|
AND t.amount >= ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_aml_ctr_report_line el
|
||||||
|
WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id)
|
||||||
|
ORDER BY t.transaction_date DESC, t.amount DESC, t.id
|
||||||
|
""";
|
||||||
|
|
||||||
|
/** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */
|
||||||
|
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
|
||||||
|
|
||||||
private final AmlCtrReportRepository repository;
|
private final AmlCtrReportRepository repository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlCtrReportData> retrieveAll() {
|
public List<AmlCtrReportData> retrieveAll() {
|
||||||
@@ -41,6 +129,46 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
|||||||
.map(this::mapToData).collect(Collectors.toList());
|
.map(this::mapToData).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AmlCtrReportLineData> retrieveLines(final Long reportId) {
|
||||||
|
final StringBuilder sql = new StringBuilder(LINES_SQL);
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
if (reportId != null) {
|
||||||
|
sql.append(" WHERE l.ctr_report_id = ? ");
|
||||||
|
args.add(reportId);
|
||||||
|
}
|
||||||
|
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
|
||||||
|
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
|
||||||
|
|
||||||
|
return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reportable cash transactions no generated report contains yet.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they
|
||||||
|
* are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which
|
||||||
|
* is what makes them disappear from this list.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<AmlCtrReportLineData> retrievePendingLines() {
|
||||||
|
return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by both queries, which select the same columns so a pending line renders like a filed one. */
|
||||||
|
private static final RowMapper<AmlCtrReportLineData> LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData(
|
||||||
|
rs.getObject("id") == null ? null : rs.getLong("id"),
|
||||||
|
rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"),
|
||||||
|
rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"),
|
||||||
|
rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||||
|
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"),
|
||||||
|
rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||||
|
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||||
|
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||||
|
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||||
|
rs.getBoolean("individual"));
|
||||||
|
|
||||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
||||||
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
||||||
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
||||||
|
|||||||
+8
-4
@@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
|
|||||||
final List<AmlAlertData> alerts = byClient.get(clientId);
|
final List<AmlAlertData> alerts = byClient.get(clientId);
|
||||||
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
|
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
// Reviewed at a lower count means new alerts have landed since — the customer has to
|
// A standing review holds the customer out until its window runs out, or until their behaviour has moved
|
||||||
// come back, otherwise one early review would silence them permanently.
|
// on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on
|
||||||
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
|
// the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back
|
||||||
|
// the day after being reviewed, for the officer to re-read the explanation they had just written.
|
||||||
|
final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size());
|
||||||
if (onlyNeedingReview && !needsReview) {
|
if (onlyNeedingReview && !needsReview) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
|
|||||||
summary.riskCategory(), alerts.size(),
|
summary.riskCategory(), alerts.size(),
|
||||||
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
||||||
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
||||||
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
|
// Open means undecided, not a named status: a status this count does not list would otherwise be
|
||||||
|
// dropped from the customer's open figure while the alert still sits in the feed awaiting review.
|
||||||
|
(int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(),
|
||||||
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
||||||
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||||
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
||||||
|
|||||||
+54
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
|||||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||||
@@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
|
|||||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||||
private final ClientRepositoryWrapper clientRepository;
|
private final ClientRepositoryWrapper clientRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final AmlAlertWritePlatformService alertWritePlatformService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
|
|||||||
|
|
||||||
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
||||||
this.context.authenticatedUser());
|
this.context.authenticatedUser());
|
||||||
|
|
||||||
|
// The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row
|
||||||
|
// and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an
|
||||||
|
// STR while the alerts sat untouched and no case ever reached the Principal Officer.
|
||||||
|
final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window);
|
||||||
|
review.recordAlertsActioned(actioned);
|
||||||
|
|
||||||
return this.reviewRepository.saveAndFlush(review).getId();
|
return this.reviewRepository.saveAndFlush(review).getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries the reviewer's conclusion through to the customer's open alerts.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* CLEARED every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
|
||||||
|
* ESCALATED every open alert is escalated, which opens an STR case for the Principal Officer
|
||||||
|
* MONITOR nothing is disposed of - the point of monitoring is that the alerts stay open
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the
|
||||||
|
* audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any
|
||||||
|
* of that here would be a second, quieter path to the same records.
|
||||||
|
*/
|
||||||
|
private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes,
|
||||||
|
final int windowDays) {
|
||||||
|
|
||||||
|
if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDateTime since = LocalDateTime.now().minusDays(windowDays);
|
||||||
|
final List<AmlAlert> openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since);
|
||||||
|
|
||||||
|
final String trail = "Recurring-behaviour review: " + notes;
|
||||||
|
|
||||||
|
if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) {
|
||||||
|
// One case for the whole pattern, not one per transaction. What is being reported is the customer's
|
||||||
|
// repeated activity, and the Principal Officer investigating it needs the transactions that make it up
|
||||||
|
// together - a case per alert would hand them four fragments of one story, and five more the next time
|
||||||
|
// this customer was reviewed.
|
||||||
|
this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail);
|
||||||
|
return openAlerts.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int actioned = 0;
|
||||||
|
for (final AmlAlert alert : openAlerts) {
|
||||||
|
this.alertWritePlatformService.whitelist(alert.getId(), trail);
|
||||||
|
actioned++;
|
||||||
|
}
|
||||||
|
return actioned;
|
||||||
|
}
|
||||||
|
|
||||||
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
|
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
|
||||||
final List<ApiParameterError> errors = new ArrayList<>();
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||||
|
|||||||
+241
-6
@@ -20,16 +20,27 @@ package org.apache.fineract.portfolio.aml.service;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -50,10 +61,41 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
private static final int MEDIUM_RISK = 2;
|
private static final int MEDIUM_RISK = 2;
|
||||||
private static final int HIGH_RISK = 3;
|
private static final int HIGH_RISK = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that carry a filing obligation in their own right, whatever else the transaction also
|
||||||
|
* matched.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or
|
||||||
|
* above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a
|
||||||
|
* behavioural rule outrank it would bury a statutory filing behind an internal observation.
|
||||||
|
*/
|
||||||
|
private static final Set<String> STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L",
|
||||||
|
"STRUCTURED_CTR_10L_MONTHLY");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rule an alert is raised under when a transaction matches several.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Statutory reporting first, because those must be filed. Then the largest threshold, which is
|
||||||
|
* the system's own measure of how far out of the ordinary the transaction is - a rule written
|
||||||
|
* for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last,
|
||||||
|
* so the same transaction always reports under the same rule rather than shifting with whatever
|
||||||
|
* order the database returned.
|
||||||
|
*/
|
||||||
|
private static final Comparator<AmlTransactionRule> RULE_PRECEDENCE = Comparator
|
||||||
|
.comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1)
|
||||||
|
.thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(),
|
||||||
|
Comparator.reverseOrder())
|
||||||
|
.thenComparing(AmlTransactionRule::getRuleCode);
|
||||||
|
|
||||||
private final AmlTransactionRuleRepository ruleRepository;
|
private final AmlTransactionRuleRepository ruleRepository;
|
||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final AmlAlertRuleRepository alertRuleRepository;
|
||||||
private final ClientKycRepository clientKycRepository;
|
private final ClientKycRepository clientKycRepository;
|
||||||
|
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||||
|
private final AmlSubjectScopeResolver subjectScopeResolver;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||||
@@ -61,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
|
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
|
||||||
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
|
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
|
||||||
|
|
||||||
final List<AmlAlert> raised = new ArrayList<>();
|
final List<AmlTransactionRule> matched = new ArrayList<>();
|
||||||
for (final AmlTransactionRule rule : candidates) {
|
for (final AmlTransactionRule rule : candidates) {
|
||||||
if (isHit(rule, context)) {
|
if (isHit(rule, context)) {
|
||||||
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
|
matched.add(rule);
|
||||||
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
|
|
||||||
raised.add(alertRepository.save(alert));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<AmlAlert> raised = new ArrayList<>();
|
||||||
|
if (!matched.isEmpty()) {
|
||||||
|
// One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000
|
||||||
|
// cash deposit arrived as two open alerts - the CTR and the new-account rule - and the
|
||||||
|
// officer had to dispose of each. Nothing stopped them whitelisting one while
|
||||||
|
// escalating the other, leaving the same deposit recorded as both cleared and
|
||||||
|
// suspicious. A monitoring system reports a transaction once, under the rule that most
|
||||||
|
// warrants attention.
|
||||||
|
matched.sort(RULE_PRECEDENCE);
|
||||||
|
final AmlTransactionRule governing = matched.get(0);
|
||||||
|
|
||||||
|
// When the alert was raised, not the transaction's value date. The context carries the date the
|
||||||
|
// transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read
|
||||||
|
// "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it
|
||||||
|
// noticed something; the value date is still on the transaction the alert points at.
|
||||||
|
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(),
|
||||||
|
governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant());
|
||||||
|
// The rules that did not govern are still recorded: an analyst needs to see that the
|
||||||
|
// deposit was both reportable and on a new account, even though it is one case.
|
||||||
|
if (matched.size() > 1) {
|
||||||
|
alert.recordAlsoMatchedRules(
|
||||||
|
matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", ")));
|
||||||
|
}
|
||||||
|
// The worst band across everything that matched, not only the rule the alert was raised
|
||||||
|
// under. A statutory CTR outranks a high-severity behavioural rule when the governing
|
||||||
|
// rule is picked, so without this the screen would see "CTR, no severity" and offer the
|
||||||
|
// transaction up for a batch decision alongside ordinary low-value alerts.
|
||||||
|
final Map<Long, Integer> bandBySourceId = severityBySourceRuleId(matched);
|
||||||
|
alert.recordHighestSeverity(highestSeverityOf(bandBySourceId));
|
||||||
|
final AmlAlert saved = alertRepository.save(alert);
|
||||||
|
|
||||||
|
// One finding per rule, so each can be disposed of on its own. Severity is copied in
|
||||||
|
// rather than read back through the rule later: the alert records what was detected at
|
||||||
|
// the time, and re-banding a rule afterwards must not rewrite that.
|
||||||
|
for (final AmlTransactionRule rule : matched) {
|
||||||
|
final Long sourceId = rule.getSourceClientRiskRuleId();
|
||||||
|
alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(),
|
||||||
|
sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing));
|
||||||
|
}
|
||||||
|
raised.add(saved);
|
||||||
|
}
|
||||||
|
|
||||||
// Every transaction type funnels through here, so the recurring-behaviour check is done once
|
// Every transaction type funnels through here, so the recurring-behaviour check is done once
|
||||||
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
|
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
|
||||||
// the batch size lets the service tell a first-time offender from a repeat one without
|
// the batch size lets the service tell a first-time offender from a repeat one without
|
||||||
@@ -80,7 +162,49 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
return raised;
|
return raised;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worst severity band among the rules that matched.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A transaction rule carries no severity of its own; it inherits the risk category of the
|
||||||
|
* catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute
|
||||||
|
* nothing, because a statutory return is not a graded suspicion. Null when none of the matched
|
||||||
|
* rules carried a severity at all.
|
||||||
|
*/
|
||||||
|
private Integer highestSeverityOf(final Map<Long, Integer> bandBySourceId) {
|
||||||
|
Integer worst = null;
|
||||||
|
for (final Integer band : bandBySourceId.values()) {
|
||||||
|
if (band != null && (worst == null || band > worst)) {
|
||||||
|
worst = band;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written
|
||||||
|
* beneath it - rather than querying the catalog twice for the same rows.
|
||||||
|
*/
|
||||||
|
private Map<Long, Integer> severityBySourceRuleId(final List<AmlTransactionRule> matched) {
|
||||||
|
final List<Long> sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null)
|
||||||
|
.distinct().toList();
|
||||||
|
if (sourceIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
final Map<Long, Integer> bandById = new HashMap<>();
|
||||||
|
for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) {
|
||||||
|
bandById.put(catalogRule.getId(), catalogRule.getRiskCategory());
|
||||||
|
}
|
||||||
|
return bandById;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
|
if (!appliesToThisTransaction(rule, context)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||||
if (threshold == null) {
|
if (threshold == null) {
|
||||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||||
@@ -91,14 +215,125 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
case THRESHOLD:
|
case THRESHOLD:
|
||||||
return context.getAmount().compareTo(threshold) >= 0;
|
return context.getAmount().compareTo(threshold) >= 0;
|
||||||
case STRUCTURING:
|
case STRUCTURING:
|
||||||
|
return isStructuringHit(rule, context, threshold);
|
||||||
case VELOCITY:
|
case VELOCITY:
|
||||||
return context.getWindowAggregateAmount() != null
|
return isVelocityHit(rule, context, threshold);
|
||||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this rule is about this transaction at all, before any amount is compared.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000
|
||||||
|
* cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low
|
||||||
|
* cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about
|
||||||
|
* transactions this was not. An analyst who cannot tell which of ten alerts is real stops
|
||||||
|
* reading them, and that is how a real one gets missed.
|
||||||
|
*/
|
||||||
|
private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
|
// A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by
|
||||||
|
// definition and is not what CTR exists to catch.
|
||||||
|
if (rule.isCashOnly() && !context.isCash()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Direction, where the rule names one. UNKNOWN means the caller could not establish it, so
|
||||||
|
// the filter is not applied rather than guessed at.
|
||||||
|
final String ruleDirection = rule.getDirection();
|
||||||
|
if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN
|
||||||
|
&& !ruleDirection.equalsIgnoreCase(context.getDirection().name())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// And who the rule is about, for rules named after an entity rather than an amount.
|
||||||
|
// PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing
|
||||||
|
// here asked whether that customer was politically exposed.
|
||||||
|
return subjectScopeResolver.matches(rule.getSubjectScope(), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structuring: many transactions each deliberately kept below a reporting threshold, summing
|
||||||
|
* to something that would have been reportable had it been done at once.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Two things separate it from one large transaction, and neither was being checked. The count:
|
||||||
|
* a pattern needs several transactions, so a single deposit is never structuring however large.
|
||||||
|
* And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000
|
||||||
|
* deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what
|
||||||
|
* that rule used to report it as.
|
||||||
|
*/
|
||||||
|
private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||||
|
if (context.getWindowAggregateAmount() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Counted and summed over the rule's own band, not over the window as a whole. A window
|
||||||
|
// holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a
|
||||||
|
// ₹10,00,000 total on the raw figures, and that is how one large deposit came to be
|
||||||
|
// reported as structuring: none of the four was in the band the rule describes.
|
||||||
|
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||||
|
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Velocity: an unusual number of transactions in a short window, whatever each one is worth.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Like structuring it needs a count — one transaction is not a velocity — but unlike structuring
|
||||||
|
* the individual amounts are not confined to a band, since the concern is the frequency itself.
|
||||||
|
*/
|
||||||
|
private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
||||||
|
if (context.getWindowAggregateAmount() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run
|
||||||
|
// of small ones, and a single ₹12,00,000 is not an instance of it however often the account
|
||||||
|
// is used.
|
||||||
|
if (!isWithinRuleBand(rule, context.getAmount())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
||||||
|
if (!hasEnoughTransactions(rule, inBand.size())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this transaction sits inside the band the rule describes.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A rule with no band stated matches any amount, which is how these rules behaved before the
|
||||||
|
* columns existed and is what a rule that only names a window total still means.
|
||||||
|
*/
|
||||||
|
private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) {
|
||||||
|
final BigDecimal min = rule.getMinLimitAmount();
|
||||||
|
final BigDecimal max = rule.getMaxLimitAmount();
|
||||||
|
if (min != null && amount.compareTo(min) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return max == null || amount.compareTo(max) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether enough transactions have happened in the window to call this a pattern.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3,
|
||||||
|
* because two large deposits in a month is ordinary business and RBI's own wording is
|
||||||
|
* "numerous".
|
||||||
|
*/
|
||||||
|
private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) {
|
||||||
|
final Integer minimum = rule.getMinTransactionCount();
|
||||||
|
return minimum == null || minimum <= 1 || countInBand >= minimum;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||||
|
|||||||
+38
-1
@@ -20,9 +20,15 @@ package org.apache.fineract.portfolio.aml.service;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
|
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -36,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
|
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
|
||||||
|
|
||||||
private final AmlStrCaseRepository strCaseRepository;
|
private final AmlStrCaseRepository strCaseRepository;
|
||||||
|
private final AmlStrCaseAlertRepository strCaseAlertRepository;
|
||||||
|
private final AmlAlertRepository alertRepository;
|
||||||
private final AppUserRepository appUserRepository;
|
private final AppUserRepository appUserRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final StrSlaCalculator strSlaCalculator;
|
private final StrSlaCalculator strSlaCalculator;
|
||||||
@@ -58,7 +66,36 @@ public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatfo
|
|||||||
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
|
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
|
||||||
: null;
|
: null;
|
||||||
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
|
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
|
||||||
return strCaseRepository.save(strCase);
|
final AmlStrCase saved = strCaseRepository.save(strCase);
|
||||||
|
|
||||||
|
// The conclusion is the answer to the referral, so it goes back to the alerts that raised it. A case concluded
|
||||||
|
// clear leaves those transactions reading as suspicious on the AML feed until this runs - the bank would have
|
||||||
|
// decided one thing and the screen an officer works from would say another.
|
||||||
|
final boolean suspicious = conclusion == AmlStrConclusion.SUSPICIOUS;
|
||||||
|
final String note = "Principal Officer concluded " + (suspicious ? "suspicious" : "clear")
|
||||||
|
+ (investigationNotes == null || investigationNotes.isBlank() ? "" : " — " + investigationNotes);
|
||||||
|
for (final AmlAlert alert : alertsOf(saved)) {
|
||||||
|
alert.applyStrConclusion(suspicious, principalOfficer, note);
|
||||||
|
alertRepository.save(alert);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every alert the case covers.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read from the link table, which holds one row per alert: a pattern case is opened over several alerts, and
|
||||||
|
* concluding it has to answer all of them rather than only the one whose id happens to sit on the case.
|
||||||
|
*/
|
||||||
|
private List<AmlAlert> alertsOf(final AmlStrCase strCase) {
|
||||||
|
final List<AmlAlert> covered = strCaseAlertRepository.findByStrCase_IdOrderByAlert_RaisedOnAsc(strCase.getId()).stream()
|
||||||
|
.map(AmlStrCaseAlert::getAlert).collect(Collectors.toList());
|
||||||
|
// Cases opened before the link table existed carry only alert_id.
|
||||||
|
if (covered.isEmpty() && strCase.getAlert() != null) {
|
||||||
|
covered.add(strCase.getAlert());
|
||||||
|
}
|
||||||
|
return covered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
|
||||||
|
* written about.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Everything here is read from what the customer record already holds - the client type chosen at
|
||||||
|
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
|
||||||
|
* because the amount is what the rule's threshold is already for.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AmlSubjectScopeResolver {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client types that make an account a charitable/not-for-profit one for the purposes of the
|
||||||
|
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
|
||||||
|
* per-tenant and these are seeded per tenant.
|
||||||
|
*/
|
||||||
|
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
|
||||||
|
* worth a second look that the same amount elsewhere would not be.
|
||||||
|
*/
|
||||||
|
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
|
||||||
|
"DEALER IN PRECIOUS METALS", "CASINO");
|
||||||
|
|
||||||
|
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
|
||||||
|
private static final int NEW_ACCOUNT_DAYS = 180;
|
||||||
|
|
||||||
|
private static final int HIGH_RISK_KYC_CATEGORY = 3;
|
||||||
|
|
||||||
|
private final ClientKycRepository clientKycRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a rule carrying this scope should look at this transaction at all.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A rule with no scope is about the transaction rather than the customer and applies to
|
||||||
|
* everyone, which is how these rules behaved before the column existed.
|
||||||
|
*/
|
||||||
|
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
|
||||||
|
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
|
||||||
|
if (scope == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final Client client = context.getClient();
|
||||||
|
if (client == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return switch (scope) {
|
||||||
|
case PEP -> isPoliticallyExposed(client);
|
||||||
|
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
|
||||||
|
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
|
||||||
|
case NEW_ACCOUNT -> isNewAccount(context);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
|
||||||
|
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
|
||||||
|
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
|
||||||
|
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
|
||||||
|
* report everyone.
|
||||||
|
*/
|
||||||
|
private boolean isPoliticallyExposed(final Client client) {
|
||||||
|
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|
||||||
|
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
|
||||||
|
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
|
||||||
|
final String type = client.clientType() == null ? null : client.clientType().getLabel();
|
||||||
|
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
|
||||||
|
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measured from the customer's activation date against the business date, not the wall clock,
|
||||||
|
* so a back-dated test and a back-dated correction both read the age the books read.
|
||||||
|
*/
|
||||||
|
private boolean isNewAccount(final AmlTransactionContext context) {
|
||||||
|
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
|
||||||
|
: context.getClient().getActivationDate();
|
||||||
|
if (openedOn == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String normalised = value.trim().toUpperCase();
|
||||||
|
return wanted.contains(normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean codeValueMatches(final String value, final String token) {
|
||||||
|
return value != null && value.trim().toUpperCase().contains(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving a customer's KYC risk category, under maker-checker.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so
|
||||||
|
* raising or lowering one is a decision about how closely the bank watches that customer. It is
|
||||||
|
* raised from the Risk Review Report - which already works out where a rating ought to sit - and
|
||||||
|
* takes effect only when a checker agrees.
|
||||||
|
*/
|
||||||
|
public interface ClientRiskChangeRequestService {
|
||||||
|
|
||||||
|
/** Raised by the reviewer. Nothing moves until a checker approves. */
|
||||||
|
CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason);
|
||||||
|
|
||||||
|
/** Applies the new category to the customer's KYC record and records the change in history. */
|
||||||
|
CommandProcessingResult approve(Long requestId);
|
||||||
|
|
||||||
|
CommandProcessingResult reject(Long requestId, String rejectionReason);
|
||||||
|
|
||||||
|
/** @param status PENDING / APPROVED / REJECTED, or null for all */
|
||||||
|
List<Map<String, Object>> retrieveAll(String status);
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService {
|
||||||
|
|
||||||
|
private static final String STATUS_PENDING = "PENDING";
|
||||||
|
private static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
private static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
|
||||||
|
/** KycRiskCategory: 1 Low, 2 Medium, 3 High. */
|
||||||
|
private static final int LOWEST_CATEGORY = 1;
|
||||||
|
private static final int HIGHEST_CATEGORY = 3;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientRepositoryWrapper clientRepository;
|
||||||
|
private final ClientKycRepositoryWrapper kycRepositoryWrapper;
|
||||||
|
private final ClientRiskCategoryHistoryRepository historyRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
|
||||||
|
if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) {
|
||||||
|
throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3).");
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("A reason is required to change a customer's risk category.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
|
||||||
|
final Integer currentCategory = kyc.getRiskCategoryValue();
|
||||||
|
|
||||||
|
if (requestedCategory.equals(currentCategory)) {
|
||||||
|
throw new IllegalStateException("The customer is already on that risk category.");
|
||||||
|
}
|
||||||
|
if (!this.jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId)
|
||||||
|
.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"A risk category change is already pending for this customer. Wait for it to be approved or rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(con -> {
|
||||||
|
final java.sql.PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO m_client_risk_change_request "
|
||||||
|
+ "(client_id, current_category, requested_category, reason, status, maker_id, created_date) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
new String[] { "id" });
|
||||||
|
ps.setLong(1, client.getId());
|
||||||
|
if (currentCategory == null) {
|
||||||
|
ps.setNull(2, java.sql.Types.INTEGER);
|
||||||
|
} else {
|
||||||
|
ps.setInt(2, currentCategory);
|
||||||
|
}
|
||||||
|
ps.setInt(3, requestedCategory);
|
||||||
|
ps.setString(4, reason);
|
||||||
|
ps.setString(5, STATUS_PENDING);
|
||||||
|
ps.setLong(6, currentUser.getId());
|
||||||
|
ps.setObject(7, now);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withClientId(client.getId())
|
||||||
|
.withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult approve(final Long requestId) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findRequestOrThrow(requestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot approve your own risk category change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long clientId = ((Number) request.get("client_id")).longValue();
|
||||||
|
final Integer requestedCategory = ((Number) request.get("requested_category")).intValue();
|
||||||
|
|
||||||
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
// Read again rather than trusted from the request: the rating may have moved since - an AML
|
||||||
|
// auto-escalation on the customer's third STR case does exactly that - and the history has
|
||||||
|
// to record where it actually moved from.
|
||||||
|
final Integer previousCategory = kyc.getRiskCategoryValue();
|
||||||
|
if (requestedCategory.equals(previousCategory)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"The customer is already on the requested risk category; the rating moved after this request was raised.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher
|
||||||
|
// is called back in sooner, which is most of what the rating is for.
|
||||||
|
kyc.escalateRiskCategory(requestedCategory);
|
||||||
|
this.kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory,
|
||||||
|
(String) request.get("reason"), currentUser, now));
|
||||||
|
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
|
||||||
|
STATUS_APPROVED, currentUser.getId(), now, requestId);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult reject(final Long requestId, final String rejectionReason) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findRequestOrThrow(requestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot reject your own risk category change.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? "
|
||||||
|
+ "WHERE id = ?",
|
||||||
|
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(requestId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT r.id AS "id",
|
||||||
|
r.client_id AS "clientId",
|
||||||
|
c.display_name AS "clientName",
|
||||||
|
c.account_no AS "cifNumber",
|
||||||
|
o.name AS "officeName",
|
||||||
|
r.current_category AS "currentCategory",
|
||||||
|
r.requested_category AS "requestedCategory",
|
||||||
|
r.reason AS "reason",
|
||||||
|
r.status AS "status",
|
||||||
|
maker.username AS "makerUsername",
|
||||||
|
to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate",
|
||||||
|
checker.username AS "checkerUsername",
|
||||||
|
to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate",
|
||||||
|
r.rejection_reason AS "rejectionReason"
|
||||||
|
FROM m_client_risk_change_request r
|
||||||
|
JOIN m_client c ON c.id = r.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
JOIN m_appuser maker ON maker.id = r.maker_id
|
||||||
|
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
|
||||||
|
WHERE 1 = 1
|
||||||
|
""");
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND r.status = ? ");
|
||||||
|
args.add(status);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY r.id DESC ");
|
||||||
|
return this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> findRequestOrThrow(final Long requestId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Risk category change request not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same checker rule the rest of the AML module uses: a rating change is a compliance
|
||||||
|
* decision, so it is approved by a manager rather than by whoever happens to be at the screen.
|
||||||
|
*/
|
||||||
|
private void validateCheckerRole(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("principal officer") || n.contains("compliance"));
|
||||||
|
if (!isChecker) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||||
*/
|
*/
|
||||||
|
// m_savings_account and m_current_account each keep their own identity sequence, so the same
|
||||||
|
// entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to
|
||||||
|
// be written for both savings and current-account entries alike, so esa/eca could both match the
|
||||||
|
// same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever
|
||||||
|
// client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of
|
||||||
|
// misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT)
|
||||||
|
// is what newer write paths tag current-account entries with instead, unambiguously; for older
|
||||||
|
// entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine
|
||||||
|
// savings transaction sets it), checked before the ambiguous current/savings guess.
|
||||||
private static final String RESOLVE_SQL = """
|
private static final String RESOLVE_SQL = """
|
||||||
SELECT je.amount AS amount,
|
SELECT je.amount AS amount,
|
||||||
je.currency_code AS currencyCode,
|
je.currency_code AS currencyCode,
|
||||||
@@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
je.loan_transaction_id AS loanTxnId,
|
je.loan_transaction_id AS loanTxnId,
|
||||||
je.entity_type_enum AS entityType,
|
je.entity_type_enum AS entityType,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
esa.client_id,
|
CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END,
|
||||||
eca.client_id,
|
CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END,
|
||||||
|
CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END,
|
||||||
eloan.client_id,
|
eloan.client_id,
|
||||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||||
sa.client_id,
|
sa.client_id,
|
||||||
@@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ
|
|||||||
) AS clientId
|
) AS clientId
|
||||||
FROM acc_gl_journal_entry je
|
FROM acc_gl_journal_entry je
|
||||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6)
|
||||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||||
|
|||||||
+77
-14
@@ -37,11 +37,15 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -71,13 +75,6 @@ public class BranchDayRegisterApiResource {
|
|||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
|
||||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
|
||||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
|
||||||
// never corrected -- without this same filter here, the close-preview's counted-in/counted-out
|
|
||||||
// figures would include that phantom amount, on top of the real cash the branch actually counted.
|
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,28 +193,32 @@ public class BranchDayRegisterApiResource {
|
|||||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
// 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
|
// 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.
|
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||||
|
//
|
||||||
|
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||||
|
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||||
|
// timestamp is not.
|
||||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
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
|
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
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
AND (std.txn_id IS NULL OR std.txn_id < ?)
|
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||||
AND (std.officer_id IS NULL
|
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
|
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)
|
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
|
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
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate), MAX_PLAUSIBLE_TXN_ID, officeId));
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
result.put("denominationIn", countedIn);
|
result.put("denominationIn", countedIn);
|
||||||
result.put("denominationOut", countedOut);
|
result.put("denominationOut", countedOut);
|
||||||
|
|
||||||
@@ -247,11 +248,54 @@ public class BranchDayRegisterApiResource {
|
|||||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||||
|
|
||||||
|
requireCashMigrationBeforeFirstEverOpen(officeId);
|
||||||
|
|
||||||
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
|
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
|
||||||
openingDifferenceReason);
|
openingDifferenceReason);
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A branch's very first business day open is also the moment its Cash at Vault GL account is
|
||||||
|
* supposed to start agreeing with what is physically in the till - that is exactly what Migrate
|
||||||
|
* Cash Denomination exists to do (count the drawer, define the GL from that count, open the day).
|
||||||
|
* This plain endpoint has no denomination-reconciliation step of its own: it just records whatever
|
||||||
|
* lump sum or count it is given as the day's opening cash, with no requirement that it line up
|
||||||
|
* with the ledger. Used for a branch's first-ever open, that would let day-to-day cash tracking
|
||||||
|
* begin while the GL silently sits at whatever it already was (typically zero on a brand-new
|
||||||
|
* tenant) - a mismatch nothing downstream would ever catch, since nothing here compares the two.
|
||||||
|
*
|
||||||
|
* Only the very first open is blocked: once Migrate Cash Denomination has posted anything to the
|
||||||
|
* cash GL for this office (regardless of the amount - even a correctly counted zero still means
|
||||||
|
* it went through the reconciliation step), every subsequent day-to-day open/close on this screen
|
||||||
|
* is untouched, exactly as it already works.
|
||||||
|
*/
|
||||||
|
private void requireCashMigrationBeforeFirstEverOpen(final Long officeId) {
|
||||||
|
final Integer priorDays = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ?",
|
||||||
|
Integer.class, officeId);
|
||||||
|
if (priorDays != null && priorDays > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Map<String, Object> gl;
|
||||||
|
try {
|
||||||
|
gl = resolveCashGlAccount();
|
||||||
|
} catch (final PlatformDataIntegrityException e) {
|
||||||
|
// No Cash at Vault / Cash at Teller account configured for this tenant at all - nothing
|
||||||
|
// for this guard to reconcile against, so the plain open proceeds exactly as it always has.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Long glAccountId = ((Number) gl.get("id")).longValue();
|
||||||
|
final Integer postedEntries = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND office_id = ? AND reversed = false",
|
||||||
|
Integer.class, glAccountId, officeId);
|
||||||
|
if (postedEntries == null || postedEntries == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.first.open.requires.cash.migration",
|
||||||
|
"This branch has never opened a business day before, and its " + gl.get("name")
|
||||||
|
+ " GL account has no opening balance yet. Use Migrate Cash Denomination to count the "
|
||||||
|
+ "vault and open the first business day together, instead of Open Day here.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The actual open-day work, factored out of {@link #openDay(String)} so
|
* The actual open-day work, factored out of {@link #openDay(String)} so
|
||||||
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
|
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
|
||||||
@@ -471,8 +515,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
final Long contraId = contraIds.get(0);
|
final Long contraId = contraIds.get(0);
|
||||||
final String currencyCode = jdbcTemplate.queryForObject(
|
final String currencyCode = jdbcTemplate.queryForObject(
|
||||||
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
|
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
|
||||||
final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
|
final String transactionId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.CASH_MIGRATION);
|
||||||
+ System.nanoTime();
|
|
||||||
|
|
||||||
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
|
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
|
||||||
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
|
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
|
||||||
@@ -482,6 +525,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
|
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
|
||||||
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
|
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
|
||||||
final AppUser currentUser) {
|
final AppUser currentUser) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, accountId);
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
|
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
|
||||||
@@ -1013,13 +1057,32 @@ public class BranchDayRegisterApiResource {
|
|||||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||||
|
|
||||||
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||||
WHERE status = 'APPROVED'
|
WHERE status = 'APPROVED'
|
||||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||||
AND (office_id = ? OR office_id IS NULL)
|
AND (office_id = ? OR office_id IS NULL)
|
||||||
""", BigDecimal.class, date, officeId));
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
|
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||||
|
// it was missing from this total entirely, understating expected cash by exactly what a
|
||||||
|
// walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
|
||||||
|
// over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
|
||||||
|
// fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
|
||||||
|
// whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
|
||||||
|
// this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
|
||||||
|
// are correctly excluded by the payment method filter.
|
||||||
|
final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
|
||||||
|
FROM m_sponsor_dd_issue_request dd
|
||||||
|
WHERE dd.status = 'APPROVED'
|
||||||
|
AND dd.payment_method = 'CASH'
|
||||||
|
AND dd.issued_date = ?
|
||||||
|
AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
|
||||||
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
|
final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
|
||||||
|
|
||||||
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
|
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
|
||||||
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
|
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
|
||||||
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
|
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
|
||||||
|
|||||||
+199
-23
@@ -87,9 +87,9 @@ public class CashierDepositRequestApiResource {
|
|||||||
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
||||||
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
||||||
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
||||||
// without a schema change. Kept well under CashReportApiResource.MAX_PLAUSIBLE_TXN_ID (10^12) so
|
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
|
||||||
// a request still sitting PENDING continues to count in cashier-position reports -- the cash is
|
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
|
||||||
// physically in the drawer already, approval or not.
|
// -- the cash is physically in the drawer already, approval or not.
|
||||||
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource {
|
|||||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||||
|
// What the counter says this GL credit is actually for — a code value under the
|
||||||
|
// "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row
|
||||||
|
// is one of the flows below picks up its own money later (Share Advance Collection, Sponsor
|
||||||
|
// DD), in which case that adoption checks this tag so it can never take a voucher meant for
|
||||||
|
// the other one. See 0565_convert_entry_type_to_code_value.xml.
|
||||||
|
final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root));
|
||||||
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
|
// DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests.
|
||||||
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
|
// Persisted so the Sponsor DD issuance screen can read the breakdown back instead of
|
||||||
// retyping face value/charges once this request is approved and selected as cash evidence.
|
// retyping face value/charges once this request is approved and selected as cash evidence.
|
||||||
@@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
|||||||
client_id, client_name, amount,
|
client_id, client_name, amount,
|
||||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
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,
|
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount)
|
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
@@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
"PENDING",
|
"PENDING",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount
|
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Globally unique voucher number from the central m_voucher register
|
// Globally unique voucher number from the central m_voucher register
|
||||||
@@ -186,7 +192,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// reject() will delete these records so the vault position is reversed.
|
// reject() will delete these records so the vault position is reversed.
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
||||||
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
|
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||||
@@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
|
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
|
||||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||||
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount"
|
+ " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id"
|
||||||
+ " FROM m_cashier_deposit_request " + where
|
+ " FROM m_cashier_deposit_request " + where
|
||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
@@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
payment_type_name, description, txn_date, status,
|
payment_type_name, description, txn_date, status,
|
||||||
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
|
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
submitted_at, actioned_at,
|
submitted_at, actioned_at,
|
||||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
|
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
|
||||||
FROM m_cashier_deposit_request
|
FROM m_cashier_deposit_request
|
||||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray()
|
pageParams.toArray()
|
||||||
@@ -358,6 +364,17 @@ public class CashierDepositRequestApiResource {
|
|||||||
updatedLog, Timestamp.from(Instant.now()), id
|
updatedLog, Timestamp.from(Instant.now()), id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rejection deleted this request's notes (the cash went back to the customer). Resubmitting
|
||||||
|
// means the cashier took the cash again, so the notes are recorded again -- without this an
|
||||||
|
// approved resubmission credited the account while the drawer never received its notes.
|
||||||
|
// Cleared first so a resubmit can never leave the old breakdown alongside the new one.
|
||||||
|
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
|
savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")),
|
||||||
|
(String) record.get("account_no"), toLong(record.get("client_id")),
|
||||||
|
currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the central voucher register in sync with the edited amount
|
// Keep the central voucher register in sync with the edited amount
|
||||||
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
|
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
|
||||||
|
|
||||||
@@ -412,13 +429,26 @@ public class CashierDepositRequestApiResource {
|
|||||||
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
|
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
|
||||||
amount, ptId, txnDateStr, description, true);
|
amount, ptId, txnDateStr, description, true);
|
||||||
} else {
|
} else {
|
||||||
// Determine real account type by checking which table owns this ID.
|
// Determine real account type by checking which table/column owns this ID.
|
||||||
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
|
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
|
||||||
// even for current accounts (submitted before the frontend fix).
|
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
|
||||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
// the very same m_savings_account table as a plain savings account, distinguished only by
|
||||||
txnId = isCurrentAccount
|
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
|
||||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
// The account_no is also checked -- m_current_account and m_savings_account each keep
|
||||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
// their own identity sequence, so the same numeric id can legitimately belong to a
|
||||||
|
// current account AND an unrelated savings account at once. An id match alone once sent
|
||||||
|
// a real deposit into a stranger's current account instead of the savings account the
|
||||||
|
// request actually named; account_no is the one value that can't collide between them.
|
||||||
|
final String requestAccountNo = (String) record.get("account_no");
|
||||||
|
final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo);
|
||||||
|
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
|
||||||
|
txnId = switch (depositCategory) {
|
||||||
|
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
|
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
|
case "RECURRING_DEPOSIT" ->
|
||||||
|
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
|
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
|
};
|
||||||
if (!isCurrentAccount && txnId != null) {
|
if (!isCurrentAccount && txnId != null) {
|
||||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||||
@@ -565,7 +595,8 @@ public class CashierDepositRequestApiResource {
|
|||||||
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
|
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
|
||||||
final String description = (String) record.get("description");
|
final String description = (String) record.get("description");
|
||||||
|
|
||||||
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
|
final String requestAccountNo = (String) record.get("account_no");
|
||||||
|
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) {
|
||||||
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
|
throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +655,12 @@ public class CashierDepositRequestApiResource {
|
|||||||
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
||||||
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
||||||
} else {
|
} else {
|
||||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
// The request's own account_no is normally right, but if a cashier ever searched by a
|
||||||
|
// short/partial account number this is where that would surface -- prefer the number
|
||||||
|
// looked up fresh from savingsAccountId, the id actually posted against.
|
||||||
|
final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
|
||||||
|
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
|
||||||
|
realAccountNo != null ? realAccountNo : accountNo, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
voucherService.addLines(voucherNo, List.of(debit, credit));
|
voucherService.addLines(voucherNo, List.of(debit, credit));
|
||||||
@@ -634,6 +670,19 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||||
|
/** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
|
||||||
|
* always querying {@code m_savings_account} would silently return an unrelated savings account's
|
||||||
|
* number whenever the two id spaces happened to collide. Every other non-GL type here is a
|
||||||
|
* {@code m_savings_account} row. */
|
||||||
|
private String savingsAccountNoOf(final String accountType, final Long accountId) {
|
||||||
|
if (accountId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
|
||||||
|
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||||
if (glAccountId == null) {
|
if (glAccountId == null) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
@@ -651,7 +700,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
txn_date, status, cashier_id, cashier_name, cashier_username,
|
txn_date, status, cashier_id, cashier_name, cashier_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
denomination_data, activity_log, submitted_at, actioned_at,
|
denomination_data, activity_log, submitted_at, actioned_at,
|
||||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount
|
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id
|
||||||
FROM m_cashier_deposit_request WHERE id = ?
|
FROM m_cashier_deposit_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
@@ -694,6 +743,64 @@ public class CashierDepositRequestApiResource {
|
|||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String executeFixedDepositDeposit(
|
||||||
|
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 date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||||
|
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
payload.addProperty("transactionDate", date);
|
||||||
|
payload.addProperty("transactionAmount", amount);
|
||||||
|
if (paymentTypeId != null) {
|
||||||
|
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||||
|
}
|
||||||
|
if (note != null && !note.isBlank()) {
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||||
|
.fixedDepositAccountDeposit(accountId)
|
||||||
|
.withJson(payload.toString())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||||
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String executeRecurringDepositDeposit(
|
||||||
|
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 date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||||
|
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
payload.addProperty("transactionDate", date);
|
||||||
|
payload.addProperty("transactionAmount", amount);
|
||||||
|
if (paymentTypeId != null) {
|
||||||
|
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||||
|
}
|
||||||
|
if (note != null && !note.isBlank()) {
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||||
|
.recurringAccountDeposit(accountId)
|
||||||
|
.withJson(payload.toString())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||||
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
||||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
||||||
@@ -774,17 +881,65 @@ public class CashierDepositRequestApiResource {
|
|||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if this account ID belongs to m_current_account (not m_savings_account). */
|
/**
|
||||||
private boolean isCurrentAccountById(final Long accountId) {
|
* Returns true if this account ID belongs to m_current_account (not m_savings_account).
|
||||||
|
*
|
||||||
|
* m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say)
|
||||||
|
* can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two
|
||||||
|
* different clients, sharing nothing but a coincidental number. An id-only check here once sent
|
||||||
|
* a savings deposit into a stranger's current account: id=1 existed in m_current_account, so
|
||||||
|
* this returned true, even though the request was for m_savings_account id=1.
|
||||||
|
*
|
||||||
|
* expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is
|
||||||
|
* only trusted once the account_no on that current-account row confirms it is truly the same
|
||||||
|
* account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller
|
||||||
|
* has no account_no to compare against, e.g. an id already known to be unambiguous).
|
||||||
|
*/
|
||||||
|
private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) {
|
||||||
try {
|
try {
|
||||||
|
if (expectedAccountNo == null || expectedAccountNo.isBlank()) {
|
||||||
|
final Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
final Integer count = jdbcTemplate.queryForObject(
|
final Integer count = jdbcTemplate.queryForObject(
|
||||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
"SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?",
|
||||||
|
Integer.class, accountId, expectedAccountNo);
|
||||||
return count != null && count > 0;
|
return count != null && count > 0;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
|
||||||
|
* rather than the request's own account_type string. FD and RD accounts live in the same
|
||||||
|
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
|
||||||
|
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
|
||||||
|
* deposit command, which does not accept a term-deposit account id.
|
||||||
|
*
|
||||||
|
* expectedAccountNo guards the current-account check against an id collision with
|
||||||
|
* m_savings_account — see {@link #isCurrentAccountById}.
|
||||||
|
*/
|
||||||
|
private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) {
|
||||||
|
if (accountId == null) {
|
||||||
|
return "SAVINGS";
|
||||||
|
}
|
||||||
|
if (isCurrentAccountById(accountId, expectedAccountNo)) {
|
||||||
|
return "CURRENT";
|
||||||
|
}
|
||||||
|
final List<Integer> depositTypes = jdbcTemplate.queryForList(
|
||||||
|
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
|
||||||
|
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
|
||||||
|
if (Integer.valueOf(200).equals(depositType)) {
|
||||||
|
return "FIXED_DEPOSIT";
|
||||||
|
}
|
||||||
|
if (Integer.valueOf(300).equals(depositType)) {
|
||||||
|
return "RECURRING_DEPOSIT";
|
||||||
|
}
|
||||||
|
return "SAVINGS";
|
||||||
|
}
|
||||||
|
|
||||||
// Parses a Fineract transaction id string to Long.
|
// Parses a Fineract transaction id string to Long.
|
||||||
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
|
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
|
||||||
private Long parseTxnIdToLong(final String txnId) {
|
private Long parseTxnIdToLong(final String txnId) {
|
||||||
@@ -860,9 +1015,14 @@ public class CashierDepositRequestApiResource {
|
|||||||
*/
|
*/
|
||||||
// Called at submit time — uses request id as temporary txn_id.
|
// Called at submit time — uses request id as temporary txn_id.
|
||||||
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value instanceof Number n ? n.longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private void savePendingDenomination(final String denominationData,
|
private void savePendingDenomination(final String denominationData,
|
||||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
final Long requestId, final Long savingsAccountId, final String accountNo,
|
||||||
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
|
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName,
|
||||||
|
final String txnDate) {
|
||||||
final JsonObject denomMap;
|
final JsonObject denomMap;
|
||||||
try {
|
try {
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
@@ -893,10 +1053,14 @@ public class CashierDepositRequestApiResource {
|
|||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
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,
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername, paymentTypeName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1178,18 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The counter's choice, when they made one — otherwise "General" under the GLEntryType code,
|
||||||
|
* resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */
|
||||||
|
private Long resolveEntryTypeCvId(final Long provided) {
|
||||||
|
if (provided != null) {
|
||||||
|
return provided;
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForObject(
|
||||||
|
"SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id"
|
||||||
|
+ " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'",
|
||||||
|
Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
private String buildActivityLog(final String existingJson, final String action,
|
private String buildActivityLog(final String existingJson, final String action,
|
||||||
final String username, final String note) {
|
final String username, final String note) {
|
||||||
JsonArray log = new JsonArray();
|
JsonArray log = new JsonArray();
|
||||||
|
|||||||
+3
-9
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
|
|||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||||
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -47,13 +48,6 @@ public class CashReconciliationApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// Mirrors CashReportApiResource.MAX_PLAUSIBLE_TXN_ID: a genuine m_savings_account_transaction id
|
|
||||||
// is always small. Some cashier-GL deposit legs stamp a synthetic, implausibly large txn_id (a
|
|
||||||
// millisecond timestamp, decoded back out of hex) that never links to a real transaction and is
|
|
||||||
// never corrected -- without this same filter here, this endpoint (unlike the cashier-position
|
|
||||||
// report) would count that phantom figure into a cashier's expected cash.
|
|
||||||
private static final long MAX_PLAUSIBLE_TXN_ID = 1_000_000_000_000L;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||||
* Returns denomination-wise expected balance for a cashier.
|
* Returns denomination-wise expected balance for a cashier.
|
||||||
@@ -96,7 +90,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
@@ -126,7 +120,7 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND (std.txn_id IS NULL OR std.txn_id < " + MAX_PLAUSIBLE_TXN_ID + ") "
|
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " 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 = ?) "
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user