Compare commits
13
Commits
317a308d8c
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af43944699 | ||
|
|
39fa100c70 | ||
|
|
1c6ac9d77f | ||
|
|
6bb512e1fe | ||
|
|
04ac233fbe | ||
|
|
9e0c04583d | ||
|
|
c20a7d0728 | ||
|
|
3d196276c8 | ||
|
|
5cc5d680f2 | ||
|
|
6aa655a8a3 | ||
|
|
518b4d4afc | ||
|
|
05bceea2d4 | ||
|
|
e33e0f23fe |
+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();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||||
|
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
@@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||||
|
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
|
|||||||
+18
@@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
PortfolioProductType.SAVING);
|
PortfolioProductType.SAVING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create-or-merge, for an expense row a product may not have yet.
|
||||||
|
*
|
||||||
|
* The plain merge variant assumes the mapping already exists, which is true of every expense
|
||||||
|
* row that shipped with the product screen. A row added later — the rounding adjustment — is
|
||||||
|
* absent on every product created before it, so editing one of those has to create the
|
||||||
|
* mapping rather than fail looking for it.
|
||||||
|
*/
|
||||||
|
public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName,
|
||||||
|
final Long productId, final int accountTypeId, final Map<String, Object> changes) {
|
||||||
|
createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE,
|
||||||
|
PortfolioProductType.SAVING);
|
||||||
|
}
|
||||||
|
|
||||||
/*** Abstractions for payments channel related to savings products ***/
|
/*** Abstractions for payments channel related to savings products ***/
|
||||||
|
|
||||||
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
||||||
@@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
|
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
// asset
|
// asset
|
||||||
@@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
|
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
|
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_UPFRONT:
|
case ACCRUAL_UPFRONT:
|
||||||
|
|||||||
+42
-5
@@ -269,7 +269,15 @@ public final class AccountingConstants {
|
|||||||
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
||||||
INCOME_FROM_INTEREST(12), //
|
INCOME_FROM_INTEREST(12), //
|
||||||
LOSSES_WRITTEN_OFF(13), //
|
LOSSES_WRITTEN_OFF(13), //
|
||||||
ESCHEAT_LIABILITY(14); //
|
ESCHEAT_LIABILITY(14), //
|
||||||
|
/**
|
||||||
|
* Where the paise dropped from a cash closure are absorbed.
|
||||||
|
*
|
||||||
|
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
|
||||||
|
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
|
||||||
|
* well clear of the accrual slots so the two sets never collide as either grows.
|
||||||
|
*/
|
||||||
|
ROUNDING_ADJUSTMENT(26); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -317,7 +325,9 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE(15), //
|
FEES_RECEIVABLE(15), //
|
||||||
PENALTIES_RECEIVABLE(16), //
|
PENALTIES_RECEIVABLE(16), //
|
||||||
INTEREST_PAYABLE(17), //
|
INTEREST_PAYABLE(17), //
|
||||||
INTEREST_RECEIVABLE(18);
|
INTEREST_RECEIVABLE(18), //
|
||||||
|
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
|
||||||
|
ROUNDING_ADJUSTMENT(26);
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -372,7 +382,8 @@ public final class AccountingConstants {
|
|||||||
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
||||||
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccountId");
|
INTEREST_PAYABLE("interestPayableAccountId"), //
|
||||||
|
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -410,7 +421,8 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE("feeReceivableAccount"), //
|
FEES_RECEIVABLE("feeReceivableAccount"), //
|
||||||
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccount"), //
|
INTEREST_PAYABLE("interestPayableAccount"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccount"); //
|
INTEREST_RECEIVABLE("interestReceivableAccount"), //
|
||||||
|
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -439,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;
|
||||||
|
|||||||
+8
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||||
|
this.actionName = "DELETE";
|
||||||
|
this.entityName = "OFFICE";
|
||||||
|
this.entityId = officeId;
|
||||||
|
this.href = "/offices/" + officeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createOfficeTransaction() {
|
public CommandWrapperBuilder createOfficeTransaction() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICETRANSACTION";
|
this.entityName = "OFFICETRANSACTION";
|
||||||
|
|||||||
+1
@@ -56,6 +56,7 @@ public enum JobName {
|
|||||||
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
||||||
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
||||||
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
||||||
|
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
|
||||||
LOAN_COB("Loan COB"), //
|
LOAN_COB("Loan COB"), //
|
||||||
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
||||||
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
||||||
|
|||||||
+11
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
|
|||||||
public void delete(final Office entity) {
|
public void delete(final Office entity) {
|
||||||
this.repository.delete(entity);
|
this.repository.delete(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
|
||||||
|
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
|
||||||
|
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
|
||||||
|
* than at transaction-commit time where it can no longer be turned into a clean error message.
|
||||||
|
*/
|
||||||
|
public void deleteAndFlush(final Office entity) {
|
||||||
|
this.repository.delete(entity);
|
||||||
|
this.repository.flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+75
-16
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
this.dinVerified = false;
|
this.dinVerified = false;
|
||||||
this.isException = false;
|
this.isException = false;
|
||||||
this.exceptionResolved = false;
|
this.exceptionResolved = false;
|
||||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||||
@@ -562,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
v -> this.form15Applicable = v);
|
v -> this.form15Applicable = v);
|
||||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
// The four exception columns are derived from whether the identity checks have passed, not
|
||||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
// data an officer types, so they are updated without recording an overwrite. A verified
|
||||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
// record demands remarks for any field it already held (see
|
||||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
|
||||||
|
// counting these would make closing a settled exception impossible without a justification
|
||||||
|
// for something nobody chose. That is what left clients verified on the KYC tab while the
|
||||||
|
// exceptions register still listed them as Pending.
|
||||||
|
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
|
||||||
|
v -> this.isException = v != null ? v : false);
|
||||||
|
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
|
||||||
|
v -> this.exceptionReason = v);
|
||||||
|
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
|
||||||
|
v -> this.exceptionResolved = v != null ? v : false);
|
||||||
|
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
|
||||||
|
v -> this.exceptionResolvedOn = v);
|
||||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||||
@@ -620,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
* populated is the record being completed rather than altered, so it is deliberately not
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* Derived-field variants of the update helpers above.
|
||||||
|
*
|
||||||
|
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
||||||
|
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
||||||
|
* kind of change a verified record demands a justification for. They still report into
|
||||||
|
* `changes`, so the update is persisted and audited like any other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
|
||||||
|
if (command.parameterExists(param)) {
|
||||||
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
|
||||||
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
|
||||||
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void recordOverwrite(final String param, final Object currentValue) {
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
final boolean wasPopulated = currentValue instanceof String text
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
? StringUtils.isNotBlank(text)
|
? StringUtils.isNotBlank(text)
|
||||||
@@ -670,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
||||||
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
|
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called after successful 3rd-party verification of all required documents. */
|
/** Called after successful 3rd-party verification of all required documents. */
|
||||||
public void markVerified(final String ckycNumber) {
|
public void markVerified(final String ckycNumber) {
|
||||||
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
||||||
this.verifiedOn = DateUtils.getLocalDateOfTenant();
|
this.verifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
|
|
||||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||||
@@ -709,21 +758,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return this.expiryDate;
|
return this.expiryDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears());
|
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
|
||||||
return this.expiryDate;
|
return this.expiryDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called when verification fails. */
|
/** Called when verification fails. */
|
||||||
public void markRejected(final String remarks) {
|
public void markRejected(final String remarks) {
|
||||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||||
this.rejectedOn = DateUtils.getLocalDateOfTenant();
|
this.rejectedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
||||||
final String aadhaarSeedingStatus, final String category, final String status) {
|
final String aadhaarSeedingStatus, final String category, final String status) {
|
||||||
this.panVerified = true;
|
this.panVerified = true;
|
||||||
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
||||||
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
||||||
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
||||||
@@ -749,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public void markAadhaarVerified() {
|
public void markAadhaarVerified() {
|
||||||
this.aadhaarVerified = true;
|
this.aadhaarVerified = true;
|
||||||
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markGstinVerified() {
|
public void markGstinVerified() {
|
||||||
this.gstinVerified = true;
|
this.gstinVerified = true;
|
||||||
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markCinVerified() {
|
public void markCinVerified() {
|
||||||
this.cinVerified = true;
|
this.cinVerified = true;
|
||||||
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified() {
|
public void markDinVerified() {
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
||||||
this.dinNumber = dinNumber;
|
this.dinNumber = dinNumber;
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.isDirector = true;
|
this.isDirector = true;
|
||||||
this.dinMcaResponse = mcaResponseJson;
|
this.dinMcaResponse = mcaResponseJson;
|
||||||
}
|
}
|
||||||
@@ -791,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
} else if (type != null && type.isNonIndividual()) {
|
} else if (type != null && type.isNonIndividual()) {
|
||||||
allDone = this.panVerified && this.gstinVerified;
|
allDone = this.panVerified && this.gstinVerified;
|
||||||
}
|
}
|
||||||
if (allDone && !getKycStatus().isVerified()) {
|
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
|
||||||
|
// periodic re-KYC is: an already-verified customer verified afresh.
|
||||||
|
//
|
||||||
|
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
|
||||||
|
// that now measures from it — frozen at the original onboarding. An officer who re-verified
|
||||||
|
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
|
||||||
|
// account still sitting on the inactive report, with nothing to say what was missing.
|
||||||
|
//
|
||||||
|
// Guarded on the verifications rather than the status, so this only fires when the checks
|
||||||
|
// that constitute a verification have actually been done again.
|
||||||
|
if (allDone) {
|
||||||
markVerified(this.ckycNumber);
|
markVerified(this.ckycNumber);
|
||||||
}
|
}
|
||||||
return allDone;
|
return allDone;
|
||||||
|
|||||||
+35
-1
@@ -93,6 +93,26 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "order_position", nullable = false)
|
@Column(name = "order_position", nullable = false)
|
||||||
private Integer orderPosition;
|
private Integer orderPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
|
||||||
|
*
|
||||||
|
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
|
||||||
|
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
|
||||||
|
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
|
||||||
|
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
|
||||||
|
* (Section 13).
|
||||||
|
*
|
||||||
|
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
|
||||||
|
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
|
||||||
|
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
|
||||||
|
* rule states what it is; nothing has to guess from the shape of its other columns.
|
||||||
|
*/
|
||||||
|
@Column(name = "rule_kind", length = 10)
|
||||||
|
private String ruleKind;
|
||||||
|
|
||||||
|
public static final String KIND_KYC = "KYC";
|
||||||
|
public static final String KIND_AML = "AML";
|
||||||
|
|
||||||
protected ClientRiskRule() {}
|
protected ClientRiskRule() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +124,9 @@ public class ClientRiskRule {
|
|||||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||||
final String dormantFlag) {
|
final String dormantFlag, final String ruleKind) {
|
||||||
final ClientRiskRule rule = new ClientRiskRule();
|
final ClientRiskRule rule = new ClientRiskRule();
|
||||||
|
rule.ruleKind = ruleKind;
|
||||||
rule.ruleName = ruleName;
|
rule.ruleName = ruleName;
|
||||||
rule.reason = reason;
|
rule.reason = reason;
|
||||||
rule.riskCategory = riskCategory;
|
rule.riskCategory = riskCategory;
|
||||||
@@ -137,6 +158,19 @@ public class ClientRiskRule {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRuleKind() {
|
||||||
|
return ruleKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this rule may take part in onboarding risk categorisation. Rows predating the
|
||||||
|
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
|
||||||
|
* into the same table.
|
||||||
|
*/
|
||||||
|
public boolean isKycRule() {
|
||||||
|
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
|
||||||
|
}
|
||||||
|
|
||||||
public String getRuleName() {
|
public String getRuleName() {
|
||||||
return ruleName;
|
return ruleName;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -256,7 +256,16 @@ public final class PostingPeriod {
|
|||||||
this.daysInYear = daysInYear;
|
this.daysInYear = daysInYear;
|
||||||
this.compoundingPeriods = compoundingPeriods;
|
this.compoundingPeriods = compoundingPeriods;
|
||||||
|
|
||||||
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
if (isUserPosting) {
|
||||||
|
// "Post interest as on D" — a closure, or the same action from the account screen. The
|
||||||
|
// period it covers ends at D-1, because interest is earned up to but not including the
|
||||||
|
// day it is paid, and SavingsHelper closes the period there. The transaction itself
|
||||||
|
// still belongs on D: that is the day the member was credited, the day the branch did
|
||||||
|
// the work, and the day the cash is handed over. Dating it to the period end instead
|
||||||
|
// stamps it a day before the account was even closed, and the ledger then disagrees with
|
||||||
|
// the closure request, the voucher and the passbook — all of which carry D.
|
||||||
|
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||||
|
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate();
|
this.dateOfPostingTransaction = periodInterval.endDate();
|
||||||
} else {
|
} else {
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -25,12 +25,14 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
||||||
@@ -77,6 +79,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
|
|||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AccountingProcessorHelper {
|
public class AccountingProcessorHelper {
|
||||||
@@ -89,6 +92,14 @@ public class AccountingProcessorHelper {
|
|||||||
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
||||||
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
|
||||||
|
* posting is routed through. The names are the contract with the product screen; the GL codes
|
||||||
|
* behind them are the bank's choice, so no code is named here.
|
||||||
|
*/
|
||||||
|
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
|
||||||
|
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
|
||||||
|
|
||||||
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
||||||
|
|
||||||
public static void setDepositAccountTypeContext(int depositAccountType) {
|
public static void setDepositAccountTypeContext(int depositAccountType) {
|
||||||
@@ -116,6 +127,10 @@ public class AccountingProcessorHelper {
|
|||||||
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
||||||
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
|
||||||
|
// JSON document rather than in acc_product_mapping, so they are read directly. See
|
||||||
|
// namedSavingsGlAccount.
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public LoanDTO populateLoanDtoFromDTO(
|
public LoanDTO populateLoanDtoFromDTO(
|
||||||
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
||||||
@@ -600,6 +615,144 @@ public class AccountingProcessorHelper {
|
|||||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest credited to a member, routed through the payable and paid accounts the product names.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Interest On Savings (expense) the bank incurs the cost
|
||||||
|
* Cr Savings Interest Payable (liability) and now owes it
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Payable
|
||||||
|
* Cr Savings Interest Paid the debt is on its way out
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Paid
|
||||||
|
* Cr Savings Control and reaches the member
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The two middle accounts net to nil across the three steps, so the trial balance is unchanged
|
||||||
|
* from the plain two-legged entry; what they add is a reading of how much interest has been
|
||||||
|
* declared but not yet credited, which a single Dr/Cr pair cannot show.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Both are read from the product's Accounting tab and neither is required. A product that names
|
||||||
|
* no paid account collapses the middle step, and one that names no payable falls back to its own
|
||||||
|
* Savings Control - either way the posting degrades to exactly the two-legged entry it was
|
||||||
|
* before, so products configured before these mappings existed are unaffected.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings}
|
||||||
|
* does for the two-legged case.
|
||||||
|
*/
|
||||||
|
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
|
||||||
|
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
|
||||||
|
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||||
|
|
||||||
|
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
|
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||||
|
|
||||||
|
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
|
||||||
|
// where a bank actually picks it. The reserved name in the product's other GL mappings is
|
||||||
|
// consulted only after that, for a product configured before that row was mapped, and the
|
||||||
|
// Savings Control last of all - interest owed is a liability either way.
|
||||||
|
final GLAccount payableAccount = Optional
|
||||||
|
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
|
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||||
|
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
||||||
|
.orElse(controlAccount);
|
||||||
|
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||||
|
.orElse(payableAccount);
|
||||||
|
|
||||||
|
// The interest expense is not raised here. It is recognised as the interest accrues, day by
|
||||||
|
// day, and this posting settles that recognised debt - raising the expense again at payout
|
||||||
|
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
|
||||||
|
// which pays a product's whole membership the same way; a closure reaching this path must
|
||||||
|
// produce the same entries as the nightly run, or the two disagree on what one payment did.
|
||||||
|
//
|
||||||
|
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
|
||||||
|
// product names no paid account both resolve to the payable and this says nothing, so it is
|
||||||
|
// skipped and the payout comes straight out of the payable.
|
||||||
|
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
||||||
|
if (!payableIsPaid) {
|
||||||
|
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
|
isReversal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: into the member's own balance.
|
||||||
|
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||||
|
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
|
isReversal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Dr/Cr pair, with both sides swapped when the transaction is a reversal.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A leg whose two sides are the same account writes nothing. That is not a defensive check but
|
||||||
|
* the mechanism by which the three stages above collapse: a product that names no payable and no
|
||||||
|
* paid account resolves all three to its Savings Control, and what should remain is the plain
|
||||||
|
* {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping
|
||||||
|
* the middle stage by hand was not enough — stage 3 still resolved to Control against Control and
|
||||||
|
* wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's
|
||||||
|
* debits and credits unequal per account and shows up on the trial balance as movement that never
|
||||||
|
* happened. Deciding it here rather than at each call site means every collapse works the same
|
||||||
|
* way, whichever of the two names a product happens to leave unset.
|
||||||
|
*/
|
||||||
|
private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount,
|
||||||
|
final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate,
|
||||||
|
final BigDecimal amount, final boolean isReversal) {
|
||||||
|
if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final GLAccount toDebit = isReversal ? creditAccount : debitAccount;
|
||||||
|
final GLAccount toCredit = isReversal ? debitAccount : creditAccount;
|
||||||
|
createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount);
|
||||||
|
createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GL account the savings product maps under one of the reserved names on its Accounting tab.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
|
||||||
|
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
|
||||||
|
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
|
||||||
|
* fixed financial account types. The name is matched across all four groups: which group a bank
|
||||||
|
* files it under is a presentation choice, and an account configured under the wrong heading
|
||||||
|
* should still be honoured rather than silently ignored.
|
||||||
|
*
|
||||||
|
* @return the account, or null where the product does not configure this name
|
||||||
|
*/
|
||||||
|
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
||||||
|
if (savingsProductId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The column holds an empty string on a product saved with no named mappings, and casting
|
||||||
|
// that to jsonb is an error rather than an empty document - so the cast is done inside a
|
||||||
|
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
|
||||||
|
// clause in any order, so guarding at the same query level as the cast would only work by
|
||||||
|
// luck.
|
||||||
|
final List<Long> found = jdbcTemplate.queryForList("""
|
||||||
|
SELECT gl.id
|
||||||
|
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||||
|
FROM m_savings_product
|
||||||
|
WHERE id = ?
|
||||||
|
AND other_gl_mappings IS NOT NULL
|
||||||
|
AND other_gl_mappings <> ''
|
||||||
|
AND other_gl_mappings LIKE '{%') sp
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||||
|
) AS m(entry)
|
||||||
|
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||||
|
WHERE m.entry ->> 'accountName' = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, savingsProductId, accountName);
|
||||||
|
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
||||||
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
||||||
@@ -1241,6 +1394,29 @@ public class AccountingProcessorHelper {
|
|||||||
return accountMapping.getGlAccount();
|
return accountMapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
|
||||||
|
* required. Interest Payable is offered on the product screen but a product configured before it
|
||||||
|
* existed has none, and that has to fall through to the next choice rather than stopping the
|
||||||
|
* account from being closed.
|
||||||
|
*/
|
||||||
|
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
|
final Long paymentTypeId) {
|
||||||
|
if (savingsProductId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
|
||||||
|
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
|
||||||
|
// which returns null for a row the cash enum does not carry - Interest Payable among them -
|
||||||
|
// and the lookup would die on a NullPointerException instead of the exception meant for it.
|
||||||
|
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
|
||||||
|
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
|
||||||
|
return mapping == null ? null : mapping.getGlAccount();
|
||||||
|
}
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
|
|||||||
+6
-3
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
|||||||
// Post journal entry if earned interest amount is greater than
|
// Post journal entry if earned interest amount is greater than
|
||||||
// zero
|
// zero
|
||||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
// Routed through the payable and paid accounts the product names, so the society
|
||||||
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
// can read interest declared but not yet credited as its own balance. Collapses
|
||||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||||
|
// that names neither.
|
||||||
|
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||||
|
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,15 +779,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 +884,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared
|
||||||
|
* by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly
|
||||||
|
* which account(s) Migrate Cash Denomination owns.
|
||||||
|
*/
|
||||||
|
private List<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);
|
||||||
|
|||||||
+2
-2
@@ -68,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
||||||
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
||||||
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
||||||
BusinessEventNotifierService businessEventNotifierService) {
|
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
|
||||||
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
||||||
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
||||||
businessEventNotifierService);
|
businessEventNotifierService, jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+9
@@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
|||||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: only mapped when the product screen supplies it. Every savings product that
|
||||||
|
// existed before this row was added has none, and a closure on one of those still has to
|
||||||
|
// work — see the closure's own fallback.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
|
||||||
|
this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element,
|
||||||
|
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId,
|
||||||
|
CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
// advanced accounting mappings
|
// advanced accounting mappings
|
||||||
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
||||||
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
||||||
|
|||||||
+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();
|
||||||
|
|||||||
+34
-15
@@ -10,6 +10,8 @@ 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.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 +24,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 +44,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 +75,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"));
|
||||||
|
|
||||||
@@ -165,7 +168,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();
|
||||||
@@ -283,7 +286,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 +345,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 +377,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, "
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SyncBusinessDateConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step syncBusinessDateStep(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
|
||||||
|
return new StepBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
|
||||||
|
.tasklet(syncBusinessDateTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job syncBusinessDateJob(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
|
||||||
|
return new JobBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
|
||||||
|
.start(syncBusinessDateStep(syncBusinessDateTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.data.service.BusinessDateDTO;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the business date to the tenant's own calendar date, every midnight.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Fineract's stock job adds one day to whatever the business date already holds. That is only the
|
||||||
|
* same thing as "today" while the scheduler has never missed a night: a server stopped over a
|
||||||
|
* weekend, a laptop that was closed, a tenant restored from an older backup, and the date is left
|
||||||
|
* behind by however many runs did not happen. It then stays behind, because adding a day to a stale
|
||||||
|
* date gives another stale date, and each night's run preserves the gap rather than closing it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Drift is not a cosmetic problem. Every transaction is stamped with the business date, so while it
|
||||||
|
* lags: a Demand Draft raised today is rejected as being dated in the future, the day's savings
|
||||||
|
* interest never accrues and a closure pays out short, and staff see yesterday's date on work they
|
||||||
|
* are doing now.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Reading the clock instead of incrementing is self-correcting: however many days were missed, the
|
||||||
|
* next run lands on today. Bringing the date forward is all this does — a date deliberately set
|
||||||
|
* ahead, which is how testing against a future day works, is left alone rather than pulled back
|
||||||
|
* under someone's feet.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class SyncBusinessDateTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final BusinessDateWritePlatformService businessDateWritePlatformService;
|
||||||
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||||
|
if (!configurationDomainService.isBusinessDateEnabled()) {
|
||||||
|
// Without the setting on, the platform reads the clock directly and m_business_date is
|
||||||
|
// not consulted — writing to it would be busywork that misleads anyone reading the table.
|
||||||
|
log.debug("Business date is not enabled; nothing to sync.");
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate systemDate = DateUtils.getLocalDateOfTenant();
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
if (!businessDate.isBefore(systemDate)) {
|
||||||
|
log.debug("Business date {} is already at or ahead of the system date {}; leaving it alone.", businessDate, systemDate);
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
|
||||||
|
businessDateWritePlatformService.updateBusinessDate(BusinessDateDTO.builder().type(BusinessDateType.BUSINESS_DATE)
|
||||||
|
.description(BusinessDateType.BUSINESS_DATE.getDescription()).date(systemDate).build());
|
||||||
|
|
||||||
|
log.info("Business date moved from {} to the system date {}.", businessDate, systemDate);
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-1
@@ -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;
|
||||||
@@ -172,6 +173,15 @@ public class SecurityConfig {
|
|||||||
// 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,
|
||||||
// it just fails inside the handler instead of at this filter.
|
// it just fails inside the handler instead of at this filter.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
|
||||||
|
// Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above
|
||||||
|
// for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary
|
||||||
|
// session when no key is sent, same as ClientAllAccountsApiResource does; permitAll
|
||||||
|
// here only opens Spring Security's outer gate for the key path, it does not skip
|
||||||
|
// the handler's own check). The PUT has no session fallback inside the handler at
|
||||||
|
// all, even for a logged-in super user — see TenantMenuAccessApiResource#save.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll()
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll()
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll()
|
||||||
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||||
// no Fineract auth required.
|
// no Fineract auth required.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||||
@@ -417,7 +427,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(),
|
||||||
@@ -454,6 +467,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\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
|
|||||||
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
||||||
.retrieveAllJobs(fineractProperties.getNodeId());
|
.retrieveAllJobs(fineractProperties.getNodeId());
|
||||||
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
||||||
|
// Release any currently_running flag left over from before this boot.
|
||||||
|
//
|
||||||
|
// The flag is set in the scheduler's veto check before a job's body runs and cleared
|
||||||
|
// by the Quartz listener when it returns. Two things leave it set with nothing
|
||||||
|
// running: a JVM that stopped mid-run, and a job started through its own API rather
|
||||||
|
// than through Quartz — no listener is involved there, so nothing ever clears it.
|
||||||
|
//
|
||||||
|
// Once set, the veto silently drops every later trigger: no run-history row, no
|
||||||
|
// error, nothing in the log. The job reads as though it ran and did nothing, and one
|
||||||
|
// dead run mutes it for good. That is how a night of unpaid savings interest goes
|
||||||
|
// unnoticed.
|
||||||
|
//
|
||||||
|
// This loop is the platform coming up, so by definition none of these jobs is
|
||||||
|
// running: whatever the flag says, it is stale. scheduleJob() below already clears
|
||||||
|
// it on the paths it reaches, but not when it throws before that line, and the
|
||||||
|
// saveOrUpdate() after this loop persists whatever we set here either way.
|
||||||
|
jobDetails.setCurrentlyRunning(false);
|
||||||
try {
|
try {
|
||||||
jobRegisterService.scheduleJob(jobDetails);
|
jobRegisterService.scheduleJob(jobDetails);
|
||||||
} catch (JobIsNotFoundOrNotEnabledException e) {
|
} catch (JobIsNotFoundOrNotEnabledException e) {
|
||||||
|
|||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.security.apiclient;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.HeaderParam;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Context;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside
|
||||||
|
* that tenant grants itself.
|
||||||
|
*
|
||||||
|
* {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks --
|
||||||
|
* but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it,
|
||||||
|
* as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally.
|
||||||
|
* A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on
|
||||||
|
* "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in
|
||||||
|
* that tenant, today or after some future role edit, would still see it. This table is the ceiling
|
||||||
|
* that check now also has to clear.
|
||||||
|
*
|
||||||
|
* Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own
|
||||||
|
* tenant's list to render their nav correctly, the same way they already read their own permissions,
|
||||||
|
* and the platform operator needs to read ANY tenant's current list before toggling it. Writing it
|
||||||
|
* accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential --
|
||||||
|
* the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no
|
||||||
|
* session fallback at all here. That is deliberate -- a bank's own super user must never be able to
|
||||||
|
* lift its own ceiling by granting themselves a permission from inside their own tenant, which is
|
||||||
|
* exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via
|
||||||
|
* {@code POST /v1/apiclients} by the platform operator and never handed to a bank.
|
||||||
|
*/
|
||||||
|
@Path("/v1/tenant-menu-access")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Tenant Menu Access",
|
||||||
|
description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TenantMenuAccessApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ApiClientCredentialService apiClientCredentialService;
|
||||||
|
private final TenantDetailsService tenantDetailsService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
||||||
|
|
||||||
|
// The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this
|
||||||
|
// list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a
|
||||||
|
// code outside this set cannot be created by the API, so a typo in a request body is silently a
|
||||||
|
// no-op rather than junk data the frontend would then have to guard against.
|
||||||
|
private static final Set<String> KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS",
|
||||||
|
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
||||||
|
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
||||||
|
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
||||||
|
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
||||||
|
* Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in
|
||||||
|
* user reads their own tenant's list exactly like they already read their own permission list
|
||||||
|
* (no elevated permission required), or the platform operator reads ANY tenant's list with the
|
||||||
|
* API key, to know the current state before toggling it on their own control screen. Only the
|
||||||
|
* write side ({@link #save}) has no session fallback -- reading configuration is not sensitive,
|
||||||
|
* changing it is.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
||||||
|
if (StringUtils.isNotBlank(apiKey)) {
|
||||||
|
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
||||||
|
} else {
|
||||||
|
context.authenticatedUser();
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access");
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject o = new JsonObject();
|
||||||
|
o.addProperty("menuCode", (String) row.get("menu_code"));
|
||||||
|
o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled")));
|
||||||
|
out.add(o);
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tenant configured on this platform, for the operator's own tenant picker -- always the
|
||||||
|
* master list from the tenant-store database, never the one tenant the caller's own
|
||||||
|
* {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name
|
||||||
|
* a real tenant for the request to reach this handler at all (see
|
||||||
|
* {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/tenants")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
||||||
|
requireApiKey(apiKey, servletRequest);
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||||
|
final JsonObject o = new JsonObject();
|
||||||
|
o.addProperty("identifier", tenant.getTenantIdentifier());
|
||||||
|
o.addProperty("name", tenant.getName());
|
||||||
|
out.add(o);
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the
|
||||||
|
* operator's own control screen. API key only -- see the class comment for why a normal session,
|
||||||
|
* even a super user's, is never accepted here.
|
||||||
|
*
|
||||||
|
* Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in
|
||||||
|
* {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than
|
||||||
|
* rejected, so the operator's screen can always send its full known set without first checking
|
||||||
|
* this deployment's exact backend version.
|
||||||
|
*/
|
||||||
|
@PUT
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest,
|
||||||
|
final String body) {
|
||||||
|
requireApiKey(apiKey, servletRequest);
|
||||||
|
|
||||||
|
final JsonArray entries = gson.fromJson(body, JsonArray.class);
|
||||||
|
if (entries == null) {
|
||||||
|
throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}.");
|
||||||
|
}
|
||||||
|
final Set<String> touched = new LinkedHashSet<>();
|
||||||
|
for (final var el : entries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String menuCode = entry.get("menuCode").getAsString();
|
||||||
|
if (!KNOWN_MENU_CODES.contains(menuCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean();
|
||||||
|
jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode);
|
||||||
|
touched.add(menuCode);
|
||||||
|
}
|
||||||
|
final JsonObject res = new JsonObject();
|
||||||
|
res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier());
|
||||||
|
res.addProperty("updated", touched.size());
|
||||||
|
return gson.toJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */
|
||||||
|
private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) {
|
||||||
|
if (StringUtils.isBlank(apiKey)) {
|
||||||
|
throw apiError(403, "This endpoint is only reachable with a platform operator API key.");
|
||||||
|
}
|
||||||
|
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveClientIp(final HttpServletRequest request) {
|
||||||
|
for (final String header : IP_HEADER_CANDIDATES) {
|
||||||
|
final String value = request.getHeader(header);
|
||||||
|
if (StringUtils.isNotBlank(value)) {
|
||||||
|
return value.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("message", message);
|
||||||
|
body.addProperty("defaultUserMessage", message);
|
||||||
|
body.addProperty("httpStatus", httpStatus);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
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.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -200,6 +201,20 @@ public class OfficesApiResource {
|
|||||||
return toApiJsonSerializer.serialize(result);
|
return toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("{officeId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
|
.deleteOffice(officeId) //
|
||||||
|
.build();
|
||||||
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
return toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/external-id/{externalId}")
|
@Path("/external-id/{externalId}")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.office.handler;
|
||||||
|
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.organisation.office.service.OfficeWritePlatformService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "OFFICE", action = "DELETE")
|
||||||
|
public class DeleteOfficeCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final OfficeWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
|
||||||
|
return this.writePlatformService.deleteOffice(command.entityId(), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -27,6 +27,8 @@ public interface OfficeWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult officeTransaction(JsonCommand command);
|
CommandProcessingResult officeTransaction(JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
||||||
|
|||||||
+75
@@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Caching;
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||||
private final OfficeTransactionRepository officeTransactionRepository;
|
private final OfficeTransactionRepository officeTransactionRepository;
|
||||||
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
||||||
|
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
|
||||||
|
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
|
||||||
|
// repository for every one of them just to count rows would be a lot of new wiring for a check
|
||||||
|
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
|
||||||
|
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
|
||||||
|
// long tail that is left is caught by the try/catch around the delete itself.
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an office outright -- distinct from every other office command, which only ever changes
|
||||||
|
* status or fields. There is no "closed" state for an office the way there is for a client or a
|
||||||
|
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
|
||||||
|
* failed with a generic error no matter how empty the office was; the endpoint it called never
|
||||||
|
* existed.
|
||||||
|
*
|
||||||
|
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
|
||||||
|
* structural block), then staff, clients and groups (the day-to-day reason an office still has
|
||||||
|
* anything in it), then any AppUser still homed there. Everything else that references m_office --
|
||||||
|
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
|
||||||
|
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
|
||||||
|
* violation on the delete itself is caught below and turned into one clear message instead. An
|
||||||
|
* office with real banking history behind it is not something this should make easy to remove either
|
||||||
|
* way, checked up front or not.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
@Caching(evict = {
|
||||||
|
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
|
||||||
|
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
|
||||||
|
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
|
||||||
|
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||||
|
|
||||||
|
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
|
||||||
|
"Move or remove its sub-offices before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
|
||||||
|
"Reassign its staff to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
|
||||||
|
"Transfer its clients to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
|
||||||
|
"Transfer its groups to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
|
||||||
|
"Reassign the users homed at this office before deleting it.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.officeRepositoryWrapper.deleteAndFlush(office);
|
||||||
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||||
|
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||||
|
} catch (final PersistenceException dve) {
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||||
|
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(officeId) //
|
||||||
|
.withOfficeId(officeId) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
|
||||||
|
final String plural, final String remedy) {
|
||||||
|
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
|
||||||
|
officeId);
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
final String noun = count == 1 ? singular : plural;
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
|
||||||
|
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
||||||
|
|||||||
+2
-2
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
|
|||||||
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||||
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
||||||
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
||||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
|
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
|
||||||
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
||||||
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
||||||
applicationCurrencyRepository);
|
applicationCurrencyRepository, jdbcTemplate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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,17 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("generate")
|
@Path("generate")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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) {
|
||||||
|
|||||||
+9
@@ -20,8 +20,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
@@ -18,13 +18,16 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
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.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -33,7 +36,40 @@ 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
|
||||||
|
""";
|
||||||
|
|
||||||
private final AmlCtrReportRepository repository;
|
private final AmlCtrReportRepository repository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlCtrReportData> retrieveAll() {
|
public List<AmlCtrReportData> retrieveAll() {
|
||||||
@@ -41,6 +77,29 @@ 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(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
|
||||||
|
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
|
||||||
|
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
|
||||||
|
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
||||||
|
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
|
||||||
|
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
||||||
|
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
||||||
|
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
||||||
|
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
||||||
|
rs.getBoolean("individual")), args.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
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());
|
||||||
|
|||||||
+118
-2
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
import org.apache.fineract.portfolio.aml.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;
|
||||||
@@ -54,6 +55,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
private final ClientKycRepository clientKycRepository;
|
private final ClientKycRepository clientKycRepository;
|
||||||
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) {
|
||||||
@@ -81,6 +83,9 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +96,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
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
-28
@@ -36,14 +36,18 @@ 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 lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.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;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branch day register — the formal opening and closing of a branch's business day.
|
* Branch day register — the formal opening and closing of a branch's business day.
|
||||||
@@ -59,6 +63,7 @@ import org.springframework.stereotype.Component;
|
|||||||
@Path("/v1/branchday")
|
@Path("/v1/branchday")
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class BranchDayRegisterApiResource {
|
public class BranchDayRegisterApiResource {
|
||||||
|
|
||||||
@@ -181,14 +186,37 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// What the cashier's own denomination entries add up to for the day. Where this and the
|
// What the cashier's own denomination entries add up to for the day. Where this and the
|
||||||
// expected figure agree, the day tallies and the closing figure is not in question.
|
// expected figure agree, the day tallies and the closing figure is not in question.
|
||||||
|
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||||
|
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||||
|
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||||
|
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||||
|
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||||
|
//
|
||||||
|
// 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(total_amount), 0) FROM m_savings_txn_denomination
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE txn_date = ? AND txn_type = 'DEPOSIT'
|
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||||
|
AND (std.officer_id IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
WHERE au.id = std.officer_id)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
|
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||||
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
|
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
AND """ + CountedCashLedger.REAL_TXN_ID + """
|
||||||
|
AND (std.officer_id IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
WHERE au.id = std.officer_id)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||||
result.put("denominationIn", countedIn);
|
result.put("denominationIn", countedIn);
|
||||||
result.put("denominationOut", countedOut);
|
result.put("denominationOut", countedOut);
|
||||||
|
|
||||||
@@ -197,9 +225,15 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// ── POST /v1/branchday/open ───────────────────────────────────────────────
|
// ── POST /v1/branchday/open ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Transactional because opening a day is three separate writes -- the register row, its
|
||||||
|
// branch-total OPENING breakdown, and each teller's carried-forward OPENING_VAULT -- and a
|
||||||
|
// failure part-way through used to commit whatever had already run. A register left open with no
|
||||||
|
// OPENING_VAULT is not a cosmetic gap: the next close's carry-forward reaches past it to an older
|
||||||
|
// register instead, and the vault's opening balance silently regresses to a stale figure.
|
||||||
@POST
|
@POST
|
||||||
@Path("open")
|
@Path("open")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
public String openDay(final String body) {
|
public String openDay(final String body) {
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
requireDayAuthority(currentUser);
|
requireDayAuthority(currentUser);
|
||||||
@@ -212,11 +246,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
|
||||||
@@ -447,6 +524,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,
|
||||||
@@ -459,9 +537,15 @@ public class BranchDayRegisterApiResource {
|
|||||||
|
|
||||||
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Transactional for the same reason as openDay: the register UPDATE, the branch-total CLOSING
|
||||||
|
// breakdown and each teller's CLOSING_VAULT snapshot are separate writes. This database still
|
||||||
|
// carries the evidence of them committing independently -- a register whose closed_at stayed
|
||||||
|
// NULL while an orphaned CLOSING_VAULT snapshot survived, which later got summed alongside a
|
||||||
|
// second snapshot and doubled a vault's carried-forward opening balance.
|
||||||
@POST
|
@POST
|
||||||
@Path("close")
|
@Path("close")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
public String closeDay(final String body) {
|
public String closeDay(final String body) {
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
requireDayAuthority(currentUser);
|
requireDayAuthority(currentUser);
|
||||||
@@ -479,6 +563,16 @@ public class BranchDayRegisterApiResource {
|
|||||||
// counted note-by-note wins over a typed-in total.
|
// counted note-by-note wins over a typed-in total.
|
||||||
final BigDecimal closingCash = denominationTotal != null ? denominationTotal
|
final BigDecimal closingCash = denominationTotal != null ? denominationTotal
|
||||||
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
|
: this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
|
||||||
|
// A close needs a figure. Without one the row commits with closing_cash and cash_difference
|
||||||
|
// NULL, and every carry-forward lookup keys on "closing_cash IS NOT NULL" -- so the next open
|
||||||
|
// skips this register entirely and resurrects the day-before's note-for-note breakdown as
|
||||||
|
// tomorrow's opening cash, with no difference to explain and nothing on screen to suggest the
|
||||||
|
// chain broke. The UI always sends one; this endpoint is directly reachable and did not check.
|
||||||
|
if (closingCash == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.closing.cash.required",
|
||||||
|
"Record what was counted before closing business day " + businessDate
|
||||||
|
+ " -- either a closing figure or a denomination breakdown.");
|
||||||
|
}
|
||||||
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
||||||
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
|
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
|
||||||
|
|
||||||
@@ -543,16 +637,46 @@ public class BranchDayRegisterApiResource {
|
|||||||
blockingCashiers.add(name + " (" + String.join(", ", issues) + ")");
|
blockingCashiers.add(name + " (" + String.join(", ", issues) + ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Overridden only by an explicit reason, and never silently.
|
||||||
|
//
|
||||||
|
// The block is the right default: a day closed over a drawer nobody counted is a day whose
|
||||||
|
// cash figure means nothing. But it cannot be absolute either. A cashier who reconciled on
|
||||||
|
// the wrong date, went on leave, or left the branch mid-shift leaves a day that can never be
|
||||||
|
// closed at all, and every day after it inherits the same block — while the screen offers a
|
||||||
|
// Force Close Day button that could not actually get past this throw, so pressing it
|
||||||
|
// returned the same refusal and read as a broken button.
|
||||||
|
//
|
||||||
|
// So a reason gets you through, and the names of the cashiers who were not reconciled are
|
||||||
|
// appended to it on the record. Somebody reading that day later sees both that it was forced
|
||||||
|
// and exactly whose cash was unaccounted for — which is more use than a day that cannot be
|
||||||
|
// closed and a button that does nothing.
|
||||||
|
String overrideReason = forceReason;
|
||||||
if (!blockingCashiers.isEmpty()) {
|
if (!blockingCashiers.isEmpty()) {
|
||||||
|
if (overrideReason == null || overrideReason.isBlank()) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled",
|
throw new PlatformDataIntegrityException("error.msg.branchday.cashiers.not.settled",
|
||||||
"Cannot close the day — the following cashiers have not been reconciled or settled: "
|
"Cannot close the day — the following cashiers have not been reconciled or settled: "
|
||||||
+ String.join("; ", blockingCashiers)
|
+ String.join("; ", blockingCashiers)
|
||||||
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day can be closed.");
|
+ ". Each cashier must reconcile their cash and settle it back to the vault before the branch day"
|
||||||
|
+ " can be closed, or record a reason to force the close.");
|
||||||
}
|
}
|
||||||
|
overrideReason = overrideReason + " [Forced over unreconciled cashiers: " + String.join("; ", blockingCashiers)
|
||||||
|
+ "]";
|
||||||
|
log.warn("Branch day {} for office {} force-closed over unreconciled cashiers: {}", businessDate, officeId,
|
||||||
|
String.join("; ", blockingCashiers));
|
||||||
|
}
|
||||||
|
// A forced close is a forced close whether or not the date is also in the past.
|
||||||
|
final boolean forcedOverCashiers = !blockingCashiers.isEmpty();
|
||||||
|
|
||||||
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
||||||
// but it must say why it was left open, and it is stamped as a force close.
|
// but it must say why it was left open, and it is stamped as a force close.
|
||||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
//
|
||||||
|
// Judged against the tenant's BUSINESS date, not the wall clock: a branch that finishes
|
||||||
|
// counting at 00:20 is still closing its own current day, and stamping that as a forced late
|
||||||
|
// close (with a mandatory "why was this left open" reason) both annoys the closer and
|
||||||
|
// destroys the one distinction this register exists for -- closed that evening versus closed
|
||||||
|
// nine days later. The same method already uses getBusinessLocalDate() for its own
|
||||||
|
// arithmetic; using two different notions of "today" in one close was the inconsistency.
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final boolean isLate = businessDate.isBefore(today);
|
final boolean isLate = businessDate.isBefore(today);
|
||||||
if (isLate && (forceReason == null || forceReason.isBlank())) {
|
if (isLate && (forceReason == null || forceReason.isBlank())) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
|
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
|
||||||
@@ -562,15 +686,31 @@ public class BranchDayRegisterApiResource {
|
|||||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||||
|
|
||||||
jdbcTemplate.update("""
|
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||||
|
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||||
|
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||||
|
// closing figure, closer and difference with no trace, and the two denomination writes
|
||||||
|
// interleaved into exactly the duplicate CLOSING rows saveDenominationDetail's
|
||||||
|
// delete-then-insert exists to prevent. Letting the database arbitrate makes the loser of
|
||||||
|
// that race fail cleanly instead.
|
||||||
|
final int closed = jdbcTemplate.update("""
|
||||||
UPDATE m_branch_day_register
|
UPDATE m_branch_day_register
|
||||||
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
|
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
|
||||||
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
||||||
WHERE office_id = ? AND business_date = ?
|
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
|
||||||
""",
|
""",
|
||||||
|
// Forced either way: a late close, or a close over a drawer nobody counted. The reason is
|
||||||
|
// stored whenever it was needed, not only when the date was in the past — a same-day
|
||||||
|
// close forced over an unreconciled cashier used to drop its reason on the floor, which
|
||||||
|
// left the one fact a reader would want to know about that day unrecorded.
|
||||||
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
|
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
|
||||||
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
|
bookCash, difference, isLate || forcedOverCashiers,
|
||||||
|
(isLate || forcedOverCashiers) ? overrideReason : null, remarks,
|
||||||
officeId, Date.valueOf(businessDate));
|
officeId, Date.valueOf(businessDate));
|
||||||
|
if (closed == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
|
||||||
|
"Business day " + businessDate + " is already closed.");
|
||||||
|
}
|
||||||
|
|
||||||
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
||||||
saveDenominationDetail(registerId, "CLOSING", denominations);
|
saveDenominationDetail(registerId, "CLOSING", denominations);
|
||||||
@@ -583,7 +723,7 @@ public class BranchDayRegisterApiResource {
|
|||||||
res.put("closingCash", closingCash);
|
res.put("closingCash", closingCash);
|
||||||
res.put("bookCash", bookCash);
|
res.put("bookCash", bookCash);
|
||||||
res.put("cashDifference", difference);
|
res.put("cashDifference", difference);
|
||||||
res.put("forceClosed", isLate);
|
res.put("forceClosed", isLate || forcedOverCashiers);
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,12 +835,30 @@ public class BranchDayRegisterApiResource {
|
|||||||
* {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier
|
* {@code tellerId} is null for the branch-total OPENING/CLOSING legs (vault + every cashier
|
||||||
* added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see
|
* added together) and set for the vault-only OPENING_VAULT/CLOSING_VAULT legs — see
|
||||||
* {@link VaultPositionService} for why the two are never allowed to mix.
|
* {@link VaultPositionService} for why the two are never allowed to mix.
|
||||||
|
*
|
||||||
|
* Replaces whatever this exact (register, leg, teller) already held rather than adding to it.
|
||||||
|
* Without that, a second call for the same snapshot -- a retry, a re-submitted close after an
|
||||||
|
* earlier attempt stalled partway, {@link #snapshotVaultDenominations} running twice for the
|
||||||
|
* same close -- leaves the old rows sitting alongside the new ones. Nothing downstream
|
||||||
|
* deduplicates by denomination before summing, so a vault's carried-forward opening balance (or
|
||||||
|
* a branch's own opening/closing figure) silently doubles instead of being replaced, exactly the
|
||||||
|
* kind of self-inflicted "the vault has more cash than the whole branch's book figure" gap this
|
||||||
|
* table exists to prevent, not cause.
|
||||||
*/
|
*/
|
||||||
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
|
private void saveDenominationDetail(final Long registerId, final String leg, final Long tellerId,
|
||||||
final List<Map<String, Object>> denominations) {
|
final List<Map<String, Object>> denominations) {
|
||||||
if (denominations == null || denominations.isEmpty()) {
|
if (denominations == null || denominations.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The bare "? IS NULL" comparison (no column on either side to infer a type from) makes
|
||||||
|
// PostgreSQL's JDBC driver fail the whole statement with "could not determine data type of
|
||||||
|
// parameter" -- unlike "teller_id = ?", which gets its type from the teller_id column even
|
||||||
|
// when the bound value is null. Casting to BIGINT gives the standalone parameter a type of
|
||||||
|
// its own, independent of context.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
DELETE FROM m_branch_day_register_detail
|
||||||
|
WHERE register_id = ? AND leg = ? AND (teller_id = ? OR (teller_id IS NULL AND CAST(? AS BIGINT) IS NULL))
|
||||||
|
""", registerId, leg, tellerId, tellerId);
|
||||||
for (final Map<String, Object> row : denominations) {
|
for (final Map<String, Object> row : denominations) {
|
||||||
final int value = (int) row.get("denominationValue");
|
final int value = (int) row.get("denominationValue");
|
||||||
final int count = (int) row.get("count");
|
final int count = (int) row.get("count");
|
||||||
@@ -909,11 +1067,32 @@ public class BranchDayRegisterApiResource {
|
|||||||
// 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
|
||||||
// the branch's expected cash by exactly what had actually been paid out that day.
|
// the branch's expected cash by exactly what had actually been paid out that day.
|
||||||
|
// Scoped to this office through the cashier who paid it out. Unscoped, this summed EVERY
|
||||||
|
// branch's withdrawals into whichever branch happened to be closing: with 6-8 offices on
|
||||||
|
// these tenants, Head Office's expected cash was being reduced by cash another branch had
|
||||||
|
// paid over its own counter.
|
||||||
|
//
|
||||||
|
// Two id conventions have to be tolerated, because cashier_id here is NOT an m_cashiers id:
|
||||||
|
// these registers store the APPUSER id of the cashier who submitted (cashier_id = 4 =
|
||||||
|
// nitesh.cashier, while that user's m_cashiers row is id 1). Resolving it only as an
|
||||||
|
// m_cashiers id matched nothing and silently excluded every withdrawal from expected cash --
|
||||||
|
// reporting a phantom shortage of exactly what had been paid out. Accepting either reading
|
||||||
|
// keeps this correct whichever convention a given register follows.
|
||||||
|
//
|
||||||
|
// A row whose cashier is not set at all is still counted, matching the "OR office_id IS NULL"
|
||||||
|
// leniency the deposits query above applies: no cashier means no branch to attribute it to,
|
||||||
|
// and dropping it silently would understate the total instead.
|
||||||
final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal slipWithdrawals = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
|
SELECT COALESCE(SUM(w.amount), 0) FROM m_clerk_withdrawal_request w
|
||||||
WHERE status = 'WITHDRAWN'
|
WHERE w.status = 'WITHDRAWN'
|
||||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
AND COALESCE(w.txn_date, CAST(w.submitted_at AS DATE)) = ?
|
||||||
""", BigDecimal.class, date));
|
AND (w.cashier_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = w.cashier_id AND t.office_id = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id = w.cashier_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId, officeId));
|
||||||
|
|
||||||
// Account closures pay cash over the same counter but live in their own register, so they
|
// Account closures pay cash over the same counter but live in their own register, so they
|
||||||
// were missing from this total entirely -- the branch's expected cash stayed high by exactly
|
// were missing from this total entirely -- the branch's expected cash stayed high by exactly
|
||||||
@@ -925,24 +1104,36 @@ public class BranchDayRegisterApiResource {
|
|||||||
// since a closure takes nothing out of the drawer until the cashier hands it over (a
|
// since a closure takes nothing out of the drawer until the cashier hands it over (a
|
||||||
// nil-balance closure never reaches a cashier at all, hence the closure_date fallback).
|
// nil-balance closure never reaches a cashier at all, hence the closure_date fallback).
|
||||||
final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal closureWithdrawals = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(cashier_disbursed_amount), 0) FROM m_clerk_closure_request
|
SELECT COALESCE(SUM(cr.cashier_disbursed_amount), 0) FROM m_clerk_closure_request cr
|
||||||
WHERE status = 'CLOSED'
|
WHERE cr.status = 'CLOSED'
|
||||||
AND COALESCE(CAST(disbursed_at AS DATE), closure_date) = ?
|
AND COALESCE(CAST(cr.disbursed_at AS DATE), cr.closure_date) = ?
|
||||||
""", BigDecimal.class, date));
|
AND (cr.cashier_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||||
|
JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE au.id = cr.cashier_id AND t.office_id = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id = cr.cashier_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId, officeId));
|
||||||
|
|
||||||
final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals);
|
final BigDecimal withdrawals = slipWithdrawals.add(closureWithdrawals);
|
||||||
|
|
||||||
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
|
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch. Scoped
|
||||||
|
// to this office by the vault it came out of, for the same reason as the withdrawals above.
|
||||||
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
|
||||||
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
|
WHERE ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
|
||||||
""", BigDecimal.class, date));
|
AND (ct.from_teller_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = ct.from_teller_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
// Cash a cashier settled back to the vault at the end of the day.
|
// Cash a cashier settled back to the vault at the end of the day, scoped to the vault it
|
||||||
|
// went back into.
|
||||||
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
|
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
SELECT COALESCE(SUM(cs.settlement_amount), 0) FROM m_cash_settlement cs
|
||||||
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
|
WHERE cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
||||||
""", BigDecimal.class, date));
|
AND (cs.to_teller_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM m_tellers t WHERE t.id = cs.to_teller_id AND t.office_id = ?))
|
||||||
|
""", BigDecimal.class, date, officeId));
|
||||||
|
|
||||||
final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
|
final BigDecimal expected = opening.add(deposits).subtract(withdrawals);
|
||||||
|
|
||||||
@@ -995,8 +1186,26 @@ public class BranchDayRegisterApiResource {
|
|||||||
cashAccountId, cashAccountId);
|
cashAccountId, cashAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A caller-supplied officeId is checked against the caller's own office hierarchy, not taken on
|
||||||
|
* trust. Without that check, any user whose role name happens to contain "clerk"/"manager"/"head
|
||||||
|
* cashier" (see {@link #canOperateDay}) could open or close ANOTHER branch's business day, and
|
||||||
|
* read its register, close preview and cash-GL balance, just by naming its id in the request --
|
||||||
|
* the authority check confirmed they may operate A day, never WHOSE day.
|
||||||
|
* {@code validateAccessRights} is the same office-hierarchy gate the client and group write
|
||||||
|
* services already use, so a head-office user retains access to their branches' days and a
|
||||||
|
* branch user is confined to their own office and anything below it.
|
||||||
|
*/
|
||||||
private Long resolveOffice(final Long requested, final AppUser user) {
|
private Long resolveOffice(final Long requested, final AppUser user) {
|
||||||
if (requested != null) {
|
if (requested != null) {
|
||||||
|
final String hierarchy = jdbcTemplate
|
||||||
|
.query("SELECT hierarchy FROM m_office WHERE id = ?", (rs, n) -> rs.getString(1), requested).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (hierarchy == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.office.not.found",
|
||||||
|
"Office " + requested + " was not found.");
|
||||||
|
}
|
||||||
|
this.context.validateAccessRights(hierarchy);
|
||||||
return requested;
|
return requested;
|
||||||
}
|
}
|
||||||
return user.getOffice() != null ? user.getOffice().getId() : 1L;
|
return user.getOffice() != null ? user.getOffice().getId() : 1L;
|
||||||
|
|||||||
+18
-1
@@ -53,15 +53,28 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
|
// Gated like every sibling register in this flow (CREATE_CASHTRANSFER,
|
||||||
|
// CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER). Cash exchange had no check at all, so any
|
||||||
|
// authenticated user -- not just a cashier -- could write legs that move a drawer's
|
||||||
|
// denomination position. Permission seeded and granted to the existing cash-handling roles by
|
||||||
|
// migration 0539.
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHEXCHANGE");
|
||||||
|
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
||||||
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
||||||
: null;
|
: null;
|
||||||
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
||||||
|
// Defaulted, never left null: every as-of-date position query filters
|
||||||
|
// "ce.exchange_date <= CAST(? AS DATE)", which is false for NULL, so a null-dated exchange
|
||||||
|
// vanished from the cashier position and reconciliation reports while still counting in the
|
||||||
|
// undated stock query (DenominationBreakdownWriter.getCashierNetPosition). The drawer then
|
||||||
|
// read as holding the notes it had given away and none of the ones it had taken in. The
|
||||||
|
// sibling registers (cash transfer, settlement) already default the same way.
|
||||||
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
||||||
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
||||||
: null;
|
: DateUtils.getBusinessLocalDate();
|
||||||
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
||||||
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
||||||
? json.get("customerIdProof").getAsString() : null;
|
? json.get("customerIdProof").getAsString() : null;
|
||||||
@@ -153,6 +166,10 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Path("{id}/reverse")
|
@Path("{id}/reverse")
|
||||||
public String reverse(@PathParam("id") Long id, String body) {
|
public String reverse(@PathParam("id") Long id, String body) {
|
||||||
|
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
|
||||||
|
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
|
||||||
|
|
||||||
CashExchange exchange = repository.findById(id)
|
CashExchange exchange = repository.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
||||||
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
||||||
|
|||||||
+27
-7
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
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.portfolio.client.domain.KycExpiryStage;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -77,7 +79,21 @@ public class CashierDepositRequestApiResource {
|
|||||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
||||||
|
|
||||||
|
// The temporary txn_id a pending deposit's denomination rows are tagged with (see
|
||||||
|
// savePendingDenomination) is this request's own id -- but m_savings_txn_denomination.txn_id has
|
||||||
|
// no reference-type discriminator, and a real m_savings_account_transaction id is drawn from the
|
||||||
|
// exact same small-integer range. Request #5 and transaction #5 coexisting is the ordinary case,
|
||||||
|
// not a rare one, so approve()'s "WHERE txn_id = <this request's id>" UPDATE (and reject()'s
|
||||||
|
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
||||||
|
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
||||||
|
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
||||||
|
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
|
||||||
|
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
|
||||||
|
// -- the cash is physically in the drawer already, approval or not.
|
||||||
|
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -132,6 +148,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw apiError(400, "Amount must be greater than zero");
|
throw apiError(400, "Amount must be greater than zero");
|
||||||
}
|
}
|
||||||
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
|
||||||
validateDenominationTotal(denominationData, amount);
|
validateDenominationTotal(denominationData, amount);
|
||||||
validateDdLimit(ddFaceValue);
|
validateDdLimit(ddFaceValue);
|
||||||
|
|
||||||
@@ -452,13 +469,15 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Denomination was already saved at submit time (cashier physically received cash then).
|
// Denomination was already saved at submit time (cashier physically received cash then).
|
||||||
// Now update the temporary txn_id (request id) to the real Fineract transaction id.
|
// Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to
|
||||||
// parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
|
// the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current)
|
||||||
|
// and hex (GL journal entry).
|
||||||
if (txnId != null) {
|
if (txnId != null) {
|
||||||
final Long realTxnId = parseTxnIdToLong(txnId);
|
final Long realTxnId = parseTxnIdToLong(txnId);
|
||||||
if (realTxnId != null) {
|
if (realTxnId != null) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id);
|
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId,
|
||||||
|
id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,9 +529,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
throw apiError(409, "This deposit request was already actioned by another manager.");
|
throw apiError(409, "This deposit request was already actioned by another manager.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse denomination — cashier physically returned cash to customer on rejection.
|
// Reverse denomination — cashier physically returned cash to customer on rejection. The
|
||||||
// The temporary denomination records (saved at submit time with txn_id = request id) are removed.
|
// temporary denomination records (saved at submit time with txn_id = offset request id -- see
|
||||||
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id);
|
// PENDING_DEPOSIT_TXN_ID_OFFSET) are removed.
|
||||||
|
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||||
}
|
}
|
||||||
@@ -875,7 +895,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
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 (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId, savingsAccountId, accountNo, clientId, denomId,
|
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername, paymentTypeName
|
||||||
);
|
);
|
||||||
|
|||||||
+87
@@ -23,12 +23,14 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -44,6 +46,10 @@ public class CashInterCashierApiResource {
|
|||||||
private final CashInterCashierDetailRepository detailRepository;
|
private final CashInterCashierDetailRepository detailRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
// The same figure the Cashier Position screen shows, so a cashier cannot hand on notes the
|
||||||
|
// report says they are not holding -- CashSettlementApiResource depends on it the same way.
|
||||||
|
private final CashReportApiResource cashReportApiResource;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -85,6 +91,21 @@ public class CashInterCashierApiResource {
|
|||||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
|
||||||
|
// stock and settlement validates the cashier's; this register validated neither, so a cashier
|
||||||
|
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
|
||||||
|
// for ever, on every future date: the receiver's position, reconciliation and settlement
|
||||||
|
// allowance all rise by ₹1,00,000 of notes that do not exist, and the sender's reads
|
||||||
|
// -195x₹500 with nothing to net it back.
|
||||||
|
validateCashierHoldsDenominations(fromCashierId, denominations, transferDate);
|
||||||
|
|
||||||
|
// Both cashiers must sit under the same office. Nothing stopped a cross-branch inter-cashier
|
||||||
|
// transfer, which moves physical cash between two branches with no vault leg on either side
|
||||||
|
// -- so one branch's day-close is short and the other's is over, with no record of a transfer
|
||||||
|
// between them anywhere in either branch's register.
|
||||||
|
validateSameOffice(fromCashierId, toCashierId);
|
||||||
|
|
||||||
final BigDecimal total = sumTotal(denominations);
|
final BigDecimal total = sumTotal(denominations);
|
||||||
|
|
||||||
CashInterCashier header = new CashInterCashier();
|
CashInterCashier header = new CashInterCashier();
|
||||||
@@ -206,6 +227,72 @@ public class CashInterCashierApiResource {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same check, same source of truth and same message shape as
|
||||||
|
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
|
||||||
|
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
|
||||||
|
* position is left alone rather than refused, matching the settlement path: a cashier with no
|
||||||
|
* recorded position yet is a setup state, not an overdraft.
|
||||||
|
*/
|
||||||
|
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
||||||
|
final LocalDate transferDate) {
|
||||||
|
|
||||||
|
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
|
||||||
|
transferDate.toString());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||||
|
if (heldRows == null || heldRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Long, Long> held = new java.util.HashMap<>();
|
||||||
|
for (final Map<String, Object> row : heldRows) {
|
||||||
|
final Object id = row.get("denominationId");
|
||||||
|
final Object count = row.get("count");
|
||||||
|
if (id instanceof Number && count instanceof Number) {
|
||||||
|
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final JsonElement el : denominations) {
|
||||||
|
final JsonObject item = el.getAsJsonObject();
|
||||||
|
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0;
|
||||||
|
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long denominationId = item.get("denominationId").getAsLong();
|
||||||
|
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||||
|
? item.get("denominationValue").getAsInt() : 0;
|
||||||
|
final long available = held.getOrDefault(denominationId, 0L);
|
||||||
|
if (count > available) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.insufficient.cashier.denomination",
|
||||||
|
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
|
||||||
|
+ ", transferring " + count + ". Check the Cashier Position report first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both cashiers must belong to the same office, via their own teller. */
|
||||||
|
private void validateSameOffice(final Long fromCashierId, final Long toCashierId) {
|
||||||
|
final List<Map<String, Object>> offices = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id AS cashier_id, t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||||
|
WHERE c.id IN (?, ?)
|
||||||
|
""", fromCashierId, toCashierId);
|
||||||
|
if (offices.size() < 2) {
|
||||||
|
// One of them has no teller/office resolvable -- leave it to the existing party checks
|
||||||
|
// rather than inventing a new failure here.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Object first = offices.get(0).get("office_id");
|
||||||
|
final Object second = offices.get(1).get("office_id");
|
||||||
|
if (first != null && second != null && !first.equals(second)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cross.office",
|
||||||
|
"Both cashiers must be in the same branch. Cash moving between branches has to go through"
|
||||||
|
+ " each branch's own vault, not a direct cashier-to-cashier transfer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveDetails(Long interCashierId, JsonArray items) {
|
private void saveDetails(Long interCashierId, JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
|
|||||||
+39
-1
@@ -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;
|
||||||
@@ -89,6 +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 " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ "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 = ?) "
|
||||||
@@ -118,6 +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 " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ "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 = ?) "
|
||||||
@@ -259,6 +262,14 @@ public class CashReconciliationApiResource {
|
|||||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||||
|
|
||||||
|
// Expected counts come from the server's own computation, never from the request body.
|
||||||
|
// Reading the client's expectedCount made the whole reconciliation self-certifying: a cashier
|
||||||
|
// Rs 40,000 short could post expectedCount == physicalCount on every row, and the record
|
||||||
|
// stored variance 0 / status OK. That row then satisfied the mandatory reconciliation
|
||||||
|
// precondition on settlement, so the shortage never surfaced anywhere in the system. The
|
||||||
|
// physical count is the cashier's to declare; what the books expect is not.
|
||||||
|
final Map<Long, Long> serverExpected = expectedCountsByDenomination(cashierId, reconDate);
|
||||||
|
|
||||||
BigDecimal expectedTotal = BigDecimal.ZERO;
|
BigDecimal expectedTotal = BigDecimal.ZERO;
|
||||||
BigDecimal physicalTotal = BigDecimal.ZERO;
|
BigDecimal physicalTotal = BigDecimal.ZERO;
|
||||||
List<CashReconciliationDetail> details = new ArrayList<>();
|
List<CashReconciliationDetail> details = new ArrayList<>();
|
||||||
@@ -266,7 +277,10 @@ public class CashReconciliationApiResource {
|
|||||||
for (JsonElement el : denominations) {
|
for (JsonElement el : denominations) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||||
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
|
final Long denomIdForExpected = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||||
|
? item.get("denominationId").getAsLong() : null;
|
||||||
|
int expectedCount = denomIdForExpected != null
|
||||||
|
? serverExpected.getOrDefault(denomIdForExpected, 0L).intValue() : 0;
|
||||||
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
||||||
int varianceCount = physicalCount - expectedCount;
|
int varianceCount = physicalCount - expectedCount;
|
||||||
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
||||||
@@ -348,6 +362,30 @@ public class CashReconciliationApiResource {
|
|||||||
rs.getLong("total_count")
|
rs.getLong("total_count")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's own expected note/coin counts for a cashier as of a date, keyed by denomination
|
||||||
|
* id. Delegates to {@link #getExpected} rather than re-deriving the eight legs: two
|
||||||
|
* implementations of "what should this drawer hold" drift apart, and the one place that matters
|
||||||
|
* is the record a settlement is later allowed on the strength of.
|
||||||
|
*/
|
||||||
|
private Map<Long, Long> expectedCountsByDenomination(final Long cashierId, final LocalDate asOfDate) {
|
||||||
|
final Map<Long, Long> expected = new java.util.HashMap<>();
|
||||||
|
final JsonObject computed = JsonParser.parseString(getExpected(cashierId, asOfDate.toString())).getAsJsonObject();
|
||||||
|
if (!computed.has("denominations") || !computed.get("denominations").isJsonArray()) {
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
for (final JsonElement el : computed.getAsJsonArray("denominations")) {
|
||||||
|
final JsonObject row = el.getAsJsonObject();
|
||||||
|
if (!row.has("denominationId") || row.get("denominationId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long count = row.has("expectedCount") && !row.get("expectedCount").isJsonNull()
|
||||||
|
? row.get("expectedCount").getAsLong() : 0L;
|
||||||
|
expected.merge(row.get("denominationId").getAsLong(), count, Long::sum);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
||||||
for (Object[] row : rows) {
|
for (Object[] row : rows) {
|
||||||
Long denomId = (Long) row[0];
|
Long denomId = (Long) row[0];
|
||||||
|
|||||||
+331
-30
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
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.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -37,7 +38,7 @@ public class CashReportApiResource {
|
|||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excludes reversed counter transactions from a cash figure.
|
* Excludes counter breakdowns that must not count towards a drawer.
|
||||||
*
|
*
|
||||||
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
|
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
|
||||||
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
|
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
|
||||||
@@ -46,13 +47,47 @@ public class CashReportApiResource {
|
|||||||
* its own reversal flag; this is the same guard for the counter, read off the transaction the
|
* its own reversal flag; this is the same guard for the counter, read off the transaction the
|
||||||
* breakdown belongs to.
|
* breakdown belongs to.
|
||||||
*
|
*
|
||||||
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD
|
* <p>
|
||||||
* register keys its rows by its own request id, not a savings transaction -- is kept rather than
|
* Two things are excluded, and they are different failures:
|
||||||
* silently dropped.
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>a breakdown whose transaction is reversed -- the money came back, so the notes did too;
|
||||||
|
* <li>a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier
|
||||||
|
* code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no
|
||||||
|
* transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position
|
||||||
|
* is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit
|
||||||
|
* Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
|
||||||
|
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
|
||||||
|
* reads the same on all four.
|
||||||
*/
|
*/
|
||||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
||||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) ";
|
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
||||||
|
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
|
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
||||||
|
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
||||||
|
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
||||||
|
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
||||||
|
//
|
||||||
|
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
|
||||||
|
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
|
||||||
|
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
|
||||||
|
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
|
||||||
|
// everything counted after it) out of the cashier's position.
|
||||||
|
//
|
||||||
|
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
||||||
|
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
|
||||||
|
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
|
||||||
|
// CountedCashLedger's rule above.
|
||||||
|
+ "AND NOT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
||||||
|
+ " WHERE mt.id = std.txn_id"
|
||||||
|
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
|
||||||
|
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
* Day book — every voucher raised on one date, in one list, newest first.
|
||||||
@@ -150,7 +185,12 @@ public class CashReportApiResource {
|
|||||||
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
||||||
// step to reach.
|
// step to reach.
|
||||||
rows.addAll(jdbcTemplate.query("""
|
rows.addAll(jdbcTemplate.query("""
|
||||||
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
SELECT r.voucher_no,
|
||||||
|
-- A closure is paid out at the counter, so it is the cashier's work.
|
||||||
|
-- This column was missing entirely and the row mapper reads it unconditionally,
|
||||||
|
-- so every CLOSURE row threw instead of appearing.
|
||||||
|
'CASHIER' AS section,
|
||||||
|
'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
||||||
'SAVINGS' AS account_type, r.client_name AS party,
|
'SAVINGS' AS account_type, r.client_name AS party,
|
||||||
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
||||||
r.payment_type_name, r.description, r.status,
|
r.payment_type_name, r.description, r.status,
|
||||||
@@ -210,6 +250,94 @@ public class CashReportApiResource {
|
|||||||
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
|
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
|
||||||
""", this::mapDayBookRow, txnDate));
|
""", this::mapDayBookRow, txnDate));
|
||||||
|
|
||||||
|
// Everything else that moved money on this date and is not one of the registers above:
|
||||||
|
// interest posted, a charge collected from a balance, a counter deposit or withdrawal, an
|
||||||
|
// escheat, a manual journal.
|
||||||
|
//
|
||||||
|
// Read from the voucher register rather than from another set of source tables, because
|
||||||
|
// that is the one place every posting lands whatever raised it — including the nightly jobs,
|
||||||
|
// which have no request row anywhere to read them from. Without this the officer's sheet
|
||||||
|
// showed only what somebody had filed a request for, and a day of system postings against
|
||||||
|
// members' accounts was invisible on the very page meant to account for the day.
|
||||||
|
//
|
||||||
|
// The registers above are excluded by reference type, so a voucher already listed as a
|
||||||
|
// deposit or a withdrawal request cannot appear twice under a different heading.
|
||||||
|
//
|
||||||
|
// Status is reported as COMPLETED: a voucher only exists once its posting has committed, so
|
||||||
|
// there is no pending state to represent — unlike a request, which can sit unactioned.
|
||||||
|
rows.addAll(jdbcTemplate.query("""
|
||||||
|
SELECT v.voucher_no,
|
||||||
|
-- A nightly batch belongs to the day it worked on, not the minute it ran.
|
||||||
|
--
|
||||||
|
-- A voucher's created_at is a wall-clock instant, while the transaction it
|
||||||
|
-- posts and the journal entry behind it both carry the business date. So the
|
||||||
|
-- interest job that fires at 00:10 stamps its vouchers with tomorrow while
|
||||||
|
-- the ledger says yesterday — and the day it actually belongs to had already
|
||||||
|
-- been counted, reported and signed off hours earlier.
|
||||||
|
--
|
||||||
|
-- Rather than re-open a closed day, such a voucher is listed on the day it
|
||||||
|
-- surfaces, under its own heading, saying which day's work it is. The date is
|
||||||
|
-- taken from the transaction it posts; where there is none — a manual journal
|
||||||
|
-- keyed straight to the ledger — an early-hours timestamp is treated the same
|
||||||
|
-- way, because nobody is at a counter at 3am.
|
||||||
|
CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
|
||||||
|
< CAST(v.created_at AS DATE)
|
||||||
|
OR (v.savings_txn_id IS NULL
|
||||||
|
AND EXTRACT(HOUR FROM v.created_at) < 6)
|
||||||
|
THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section,
|
||||||
|
-- Which day's work this is, for the heading on the prior-day block.
|
||||||
|
COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date,
|
||||||
|
CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side,
|
||||||
|
v.voucher_type AS kind,
|
||||||
|
-- The member's own account number, never a voucher line's — a line carries a
|
||||||
|
-- GL code there, and showing 500201 in an Account column reads as an account
|
||||||
|
-- that does not exist. A journal with no member behind it shows none.
|
||||||
|
sa.account_no,
|
||||||
|
NULL AS account_type,
|
||||||
|
COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party,
|
||||||
|
v.amount,
|
||||||
|
NULL AS payment_type_name,
|
||||||
|
-- The two sides in one phrase, which is what a journal voucher is read as.
|
||||||
|
CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description,
|
||||||
|
'COMPLETED' AS status,
|
||||||
|
v.created_by_username AS handled_by,
|
||||||
|
-- Who checked it, if anybody has. Not aliased from verifyCols below, which
|
||||||
|
-- selects verified_by_name under its own name for the verification panel --
|
||||||
|
-- the row mapper reads both columns, so each has to be present exactly once.
|
||||||
|
vv.verified_by_name AS approved_by,
|
||||||
|
CAST(v.savings_txn_id AS VARCHAR) AS txn_id,
|
||||||
|
v.created_at AS submitted_at, v.created_at AS actioned_at,
|
||||||
|
""" + verifyCols + """
|
||||||
|
FROM m_voucher v
|
||||||
|
-- Either link: savings_txn_id is stamped by a step after the voucher is written
|
||||||
|
-- and is empty on every interest posting, where reference_id already carries the
|
||||||
|
-- same transaction id. Without the fallback the prior-day test never fires on the
|
||||||
|
-- nightly postings it was written for.
|
||||||
|
LEFT JOIN m_savings_account_transaction t
|
||||||
|
ON t.id = COALESCE(v.savings_txn_id,
|
||||||
|
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
|
||||||
|
THEN v.reference_id END)
|
||||||
|
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
|
||||||
|
ORDER BY line_no LIMIT 1) dr ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||||
|
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||||
|
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no
|
||||||
|
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
||||||
|
va ON va.voucher_no = v.voucher_no
|
||||||
|
WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE)
|
||||||
|
AND (v.reference_type IS NULL OR v.reference_type NOT IN (
|
||||||
|
'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST',
|
||||||
|
'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST',
|
||||||
|
'SPONSOR_DD_ISSUE_REQUEST'))
|
||||||
|
""", this::mapDayBookRow, txnDate));
|
||||||
|
|
||||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
||||||
// ordered as one result set, and the row count for a single day is small.
|
// ordered as one result set, and the row count for a single day is small.
|
||||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
||||||
@@ -218,6 +346,10 @@ public class CashReportApiResource {
|
|||||||
final List<Map<String, Object>> filtered = new ArrayList<>();
|
final List<Map<String, Object>> filtered = new ArrayList<>();
|
||||||
BigDecimal creditTotal = BigDecimal.ZERO;
|
BigDecimal creditTotal = BigDecimal.ZERO;
|
||||||
BigDecimal debitTotal = BigDecimal.ZERO;
|
BigDecimal debitTotal = BigDecimal.ZERO;
|
||||||
|
// Kept apart from the day's own totals so a reader can see the overnight batch without it
|
||||||
|
// silently joining figures that were already reported and signed.
|
||||||
|
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
for (final Map<String, Object> row : rows) {
|
for (final Map<String, Object> row : rows) {
|
||||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||||
@@ -236,8 +368,12 @@ public class CashReportApiResource {
|
|||||||
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
||||||
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
||||||
final String rowStatus = (String) row.get("status");
|
final String rowStatus = (String) row.get("status");
|
||||||
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
// A batch that posted against yesterday is listed today but is not today's money: it
|
||||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
|
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
||||||
|
// it to this day's totals would make them disagree with the ledger for either date.
|
||||||
|
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
||||||
|
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||||
|
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
if ("CR".equals(row.get("side"))) {
|
if ("CR".equals(row.get("side"))) {
|
||||||
@@ -247,6 +383,16 @@ public class CashReportApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (priorDay) {
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||||
|
if (amount != null) {
|
||||||
|
if ("CR".equals(row.get("side"))) {
|
||||||
|
priorCreditTotal = priorCreditTotal.add(amount);
|
||||||
|
} else {
|
||||||
|
priorDebitTotal = priorDebitTotal.add(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.sort((a, b) -> {
|
filtered.sort((a, b) -> {
|
||||||
@@ -272,9 +418,28 @@ public class CashReportApiResource {
|
|||||||
result.put("creditTotal", creditTotal);
|
result.put("creditTotal", creditTotal);
|
||||||
result.put("debitTotal", debitTotal);
|
result.put("debitTotal", debitTotal);
|
||||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
result.put("netTotal", creditTotal.subtract(debitTotal));
|
||||||
|
// The overnight batch, reported separately: this is yesterday's work surfacing today.
|
||||||
|
result.put("priorCreditTotal", priorCreditTotal);
|
||||||
|
result.put("priorDebitTotal", priorDebitTotal);
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date column that only some of the union'd queries select.
|
||||||
|
*
|
||||||
|
* The six day-book queries do not share a column list, and adding a column to all of them so one
|
||||||
|
* can carry it would mean five queries stating a date they were handed as a parameter. Absent is
|
||||||
|
* reported as null rather than as an error.
|
||||||
|
*/
|
||||||
|
private String optionalDate(final java.sql.ResultSet rs, final String column) {
|
||||||
|
try {
|
||||||
|
final java.sql.Date value = rs.getDate(column);
|
||||||
|
return value == null ? null : value.toLocalDate().toString();
|
||||||
|
} catch (final java.sql.SQLException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
row.put("voucherNo", rs.getString("voucher_no"));
|
||||||
@@ -282,6 +447,12 @@ public class CashReportApiResource {
|
|||||||
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
|
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
|
||||||
// where the entry came from, not who typed it.
|
// where the entry came from, not who typed it.
|
||||||
row.put("section", rs.getString("section"));
|
row.put("section", rs.getString("section"));
|
||||||
|
|
||||||
|
// Which day's work the row is: normally the day being reported, but a nightly batch posts
|
||||||
|
// against the previous business date while surfacing on this one. Only the SYSTEM query
|
||||||
|
// selects it, so it is read defensively rather than added to five other queries that would
|
||||||
|
// all report the same date they were asked for.
|
||||||
|
row.put("effectiveDate", optionalDate(rs, "effective_date"));
|
||||||
row.put("side", rs.getString("side"));
|
row.put("side", rs.getString("side"));
|
||||||
row.put("kind", rs.getString("kind"));
|
row.put("kind", rs.getString("kind"));
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
row.put("accountNo", rs.getString("account_no"));
|
||||||
@@ -334,39 +505,93 @@ public class CashReportApiResource {
|
|||||||
+ " 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 = ?) " : "";
|
||||||
|
|
||||||
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
// Every note and coin that reached the drawer or left it, per denomination.
|
||||||
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
//
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
|
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
|
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
|
||||||
+ "FROM m_savings_txn_denomination std "
|
// counted against. So the vault allocation and the settlement back are included on the same
|
||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
// footing as the counter work, and each row says where its notes came from.
|
||||||
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
//
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
// Cashier scoping differs per leg because each table records the party differently: the
|
||||||
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
// counter tables name the acting user, the cash tables name the cashier row.
|
||||||
|
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||||
|
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||||
|
|
||||||
|
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
|
||||||
|
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
|
||||||
|
+ "FROM ("
|
||||||
|
// Counter work: what customers handed over and were paid.
|
||||||
|
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
|
||||||
|
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
|
||||||
|
+ " FROM m_savings_txn_denomination std "
|
||||||
|
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
||||||
|
+ " UNION ALL "
|
||||||
|
// Drawn from the vault in the morning.
|
||||||
|
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
|
||||||
|
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||||
|
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
|
||||||
|
+ vaultCashierFilter
|
||||||
|
+ " UNION ALL "
|
||||||
|
// Handed back to the vault at close.
|
||||||
|
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
|
||||||
|
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
||||||
|
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
|
||||||
|
+ settleCashierFilter
|
||||||
|
+ ") legs "
|
||||||
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
|
||||||
|
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
|
||||||
|
+ "ORDER BY legs.denomination_value DESC, dm.kind";
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
final List<Object> args = new ArrayList<>();
|
||||||
args.add(txnDate);
|
args.add(txnDate);
|
||||||
if (scopedCashierId != null) {
|
if (scopedCashierId != null) {
|
||||||
args.add(scopedCashierId);
|
args.add(scopedCashierId);
|
||||||
}
|
}
|
||||||
|
args.add(txnDate);
|
||||||
|
if (scopedCashierId != null) {
|
||||||
|
args.add(scopedCashierId);
|
||||||
|
}
|
||||||
|
args.add(txnDate);
|
||||||
|
if (scopedCashierId != null) {
|
||||||
|
args.add(scopedCashierId);
|
||||||
|
}
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
long depositCount = rs.getLong("deposit_count");
|
final long vaultInCount = rs.getLong("vault_in_count");
|
||||||
long withdrawalCount = rs.getLong("withdrawal_count");
|
final long depositCount = rs.getLong("deposit_count");
|
||||||
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
final long withdrawalCount = rs.getLong("withdrawal_count");
|
||||||
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
final long settledOutCount = rs.getLong("settled_out_count");
|
||||||
|
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
|
||||||
|
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||||
|
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||||
|
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
|
||||||
|
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("denominationId", rs.getLong("denomination_id"));
|
row.put("denominationId", rs.getLong("denomination_id"));
|
||||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||||
row.put("kind", rs.getString("kind"));
|
row.put("kind", rs.getString("kind"));
|
||||||
|
row.put("vaultInCount", vaultInCount);
|
||||||
|
row.put("vaultInAmount", vaultInAmount);
|
||||||
row.put("depositCount", depositCount);
|
row.put("depositCount", depositCount);
|
||||||
row.put("depositAmount", depositAmount);
|
row.put("depositAmount", depositAmount);
|
||||||
row.put("withdrawalCount", withdrawalCount);
|
row.put("withdrawalCount", withdrawalCount);
|
||||||
row.put("withdrawalAmount", withdrawalAmount);
|
row.put("withdrawalAmount", withdrawalAmount);
|
||||||
row.put("netCount", depositCount - withdrawalCount);
|
row.put("settledOutCount", settledOutCount);
|
||||||
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
row.put("settledOutAmount", settledOutAmount);
|
||||||
|
// What is left of this denomination: everything that came in, less everything that went
|
||||||
|
// out. This is the figure a teller counts their notes against, per note.
|
||||||
|
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
|
||||||
|
row.put("netAmount",
|
||||||
|
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
|
||||||
return row;
|
return row;
|
||||||
}, args.toArray());
|
}, args.toArray());
|
||||||
|
|
||||||
@@ -488,10 +713,49 @@ public class CashReportApiResource {
|
|||||||
result.put("openingBalance", openingBalance);
|
result.put("openingBalance", openingBalance);
|
||||||
result.put("closingBalance", closingBalance);
|
result.put("closingBalance", closingBalance);
|
||||||
result.put("cashMovement", closingBalance.subtract(openingBalance));
|
result.put("cashMovement", closingBalance.subtract(openingBalance));
|
||||||
|
|
||||||
|
// The day on its own terms, which is what a cashier actually reconciles.
|
||||||
|
//
|
||||||
|
// A cashier does not carry a balance: they draw cash from the vault in the morning, work
|
||||||
|
// the counter, and hand it back at close. So the cumulative figures above answer a
|
||||||
|
// question nobody at the counter asks — and one carrying years of history answers it
|
||||||
|
// with a number that cannot be counted against anything on the desk. These four are the
|
||||||
|
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||||
|
// back. What is left is what should physically be in the drawer.
|
||||||
|
result.put("todayVaultIn", dayLegTotal(
|
||||||
|
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||||
|
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||||
|
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||||
|
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||||
|
scopedCashierId, txnDate));
|
||||||
|
result.put("todaySettledOut", dayLegTotal(
|
||||||
|
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||||
|
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||||
|
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||||
|
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||||
|
scopedCashierId, txnDate));
|
||||||
|
result.put("todayInterCashierIn", dayLegTotal(
|
||||||
|
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||||
|
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||||
|
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||||
|
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||||
|
scopedCashierId, txnDate));
|
||||||
|
result.put("todayInterCashierOut", dayLegTotal(
|
||||||
|
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||||
|
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||||
|
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||||
|
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||||
|
scopedCashierId, txnDate));
|
||||||
}
|
}
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */
|
||||||
|
private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) {
|
||||||
|
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate);
|
||||||
|
return total == null ? BigDecimal.ZERO : total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one cashier is holding at the end of a given date, in rupees.
|
* What one cashier is holding at the end of a given date, in rupees.
|
||||||
*
|
*
|
||||||
@@ -826,7 +1090,17 @@ public class CashReportApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
|
/**
|
||||||
|
* Denomination-wise net cash a cashier is holding as of a date: everything allocated to them,
|
||||||
|
* settled or handed on by them, and taken or paid over their counter, netted per note and coin.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Public because the settlement register checks against it before accepting a hand-over. It has
|
||||||
|
* to be this figure and not a second sum of the same eight legs: two implementations of "what
|
||||||
|
* does this drawer hold" drift, and the one place that matters is the moment somebody is told
|
||||||
|
* they cannot settle what they do not have.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
|
||||||
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
||||||
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
||||||
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||||
@@ -1065,21 +1339,42 @@ public class CashReportApiResource {
|
|||||||
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
||||||
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
||||||
|
|
||||||
// 2. Paged data query
|
// 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
|
||||||
final String dataSql = "SELECT "
|
// entry ever has a denomination breakdown for -- so on its own it names only the cash side
|
||||||
|
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
|
||||||
|
// entry actually moved money between. Every voucher raised through VoucherService (both this
|
||||||
|
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
|
||||||
|
// the same table the My Work register reads its own debit/credit columns from -- joined here
|
||||||
|
// by voucher_no so this report shows the identical pair rather than only the half this
|
||||||
|
// table's own denomination row happens to know about.
|
||||||
|
final String baseSql = "SELECT "
|
||||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
||||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
||||||
+ " std.account_no AS ac_no, "
|
+ " std.account_no AS ac_no, "
|
||||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
||||||
+ " MAX(std.txn_date) AS txn_date, "
|
+ " MAX(std.txn_date) AS txn_date, "
|
||||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||||
|
+ " MAX(std.created_at) AS sort_key, "
|
||||||
+ " MAX(std.officer_username) AS teller_name, "
|
+ " MAX(std.officer_username) AS teller_name, "
|
||||||
+ " MAX(std.payment_type) AS payment_type, "
|
+ " MAX(std.payment_type) AS payment_type, "
|
||||||
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
||||||
+ baseGroupSql
|
+ baseGroupSql;
|
||||||
+ "ORDER BY MAX(std.created_at) "
|
|
||||||
|
final String dataSql = "SELECT b.*, "
|
||||||
|
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
|
||||||
|
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
|
||||||
|
+ "FROM (" + baseSql + ") b "
|
||||||
|
+ "LEFT JOIN LATERAL ("
|
||||||
|
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||||
|
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'"
|
||||||
|
+ " ORDER BY line_no LIMIT 1) dr ON TRUE "
|
||||||
|
+ "LEFT JOIN LATERAL ("
|
||||||
|
+ " SELECT party_name, account_no FROM m_voucher_line"
|
||||||
|
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'"
|
||||||
|
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
|
||||||
|
+ "ORDER BY b.sort_key "
|
||||||
+ "LIMIT ? OFFSET ?";
|
+ "LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
// Append limit and offset to params
|
// Append limit and offset to params
|
||||||
@@ -1100,6 +1395,12 @@ public class CashReportApiResource {
|
|||||||
row.put("txnSource", rs.getString("txn_source"));
|
row.put("txnSource", rs.getString("txn_source"));
|
||||||
row.put("dr", rs.getBigDecimal("dr_amount"));
|
row.put("dr", rs.getBigDecimal("dr_amount"));
|
||||||
row.put("cr", rs.getBigDecimal("cr_amount"));
|
row.put("cr", rs.getBigDecimal("cr_amount"));
|
||||||
|
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
|
||||||
|
// was raised before m_voucher_line existed, or belongs to no register that writes one.
|
||||||
|
row.put("debitParty", rs.getString("debit_party"));
|
||||||
|
row.put("debitAccount", rs.getString("debit_account"));
|
||||||
|
row.put("creditParty", rs.getString("credit_party"));
|
||||||
|
row.put("creditAccount", rs.getString("credit_account"));
|
||||||
return row;
|
return row;
|
||||||
}, pagedParams);
|
}, pagedParams);
|
||||||
|
|
||||||
|
|||||||
+12
@@ -89,6 +89,17 @@ public class VaultPositionService {
|
|||||||
""", officeId, dateFilter, tellerId);
|
""", officeId, dateFilter, tellerId);
|
||||||
String anchorLeg = "OPENING_VAULT";
|
String anchorLeg = "OPENING_VAULT";
|
||||||
if (anchorRows.isEmpty()) {
|
if (anchorRows.isEmpty()) {
|
||||||
|
// The branch-total OPENING leg mixes the vault with whatever every cashier under it
|
||||||
|
// holds, so it is only a defensible stand-in for a vault when the office has exactly one
|
||||||
|
// teller. With two, EVERY teller without a vault-scoped row of its own would anchor on
|
||||||
|
// the same office-wide breakdown, and the frontend's Branch Total -- which sums each
|
||||||
|
// teller's vault -- would report roughly twice the branch's actual cash. Worse, a close
|
||||||
|
// then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open
|
||||||
|
// carries it forward, making the phantom permanent. A second teller with no history of
|
||||||
|
// its own genuinely holds nothing, and returning no breakdown says so honestly.
|
||||||
|
final Integer tellerCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?",
|
||||||
|
Integer.class, officeId);
|
||||||
|
if (tellerCount != null && tellerCount == 1) {
|
||||||
anchorLeg = "OPENING";
|
anchorLeg = "OPENING";
|
||||||
anchorRows = jdbcTemplate.queryForList("""
|
anchorRows = jdbcTemplate.queryForList("""
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
||||||
@@ -98,6 +109,7 @@ public class VaultPositionService {
|
|||||||
ORDER BY r.business_date DESC LIMIT 1
|
ORDER BY r.business_date DESC LIMIT 1
|
||||||
""", officeId, dateFilter);
|
""", officeId, dateFilter);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (anchorRows.isEmpty()) {
|
if (anchorRows.isEmpty()) {
|
||||||
result.put("hasBreakdown", false);
|
result.put("hasBreakdown", false);
|
||||||
|
|||||||
+76
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
@@ -57,9 +58,21 @@ public class CashSettlementApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
|
// The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report
|
||||||
|
// says they are not holding.
|
||||||
|
private final CashReportApiResource cashReportApiResource;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// Transactional so the reconciliation precondition and the duplicate-settlement check below are
|
||||||
|
// read in the same transaction that writes the settlement. They used to run in autocommit while
|
||||||
|
// the insert happened later inside its own TransactionTemplate, leaving a window where a
|
||||||
|
// double-clicked Submit passed both checks twice and wrote two settlements for the same
|
||||||
|
// cashier/date -- each subtracted cumulatively, so the drawer read permanently negative by the
|
||||||
|
// settlement amount. Migration 0540 adds the unique index that makes the race impossible rather
|
||||||
|
// than merely unlikely; this annotation is what stops the two halves of the check-then-act from
|
||||||
|
// being separated by another request's commit.
|
||||||
@POST
|
@POST
|
||||||
|
@Transactional
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
||||||
|
|
||||||
@@ -112,6 +125,8 @@ public class CashSettlementApiResource {
|
|||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
|
||||||
|
|
||||||
final BigDecimal total = sumTotal(denominations);
|
final BigDecimal total = sumTotal(denominations);
|
||||||
|
|
||||||
CashSettlement header = new CashSettlement();
|
CashSettlement header = new CashSettlement();
|
||||||
@@ -290,6 +305,67 @@ public class CashSettlementApiResource {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a hand-over of notes the cashier is not holding.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Nothing stopped this before, and the damage is not a rejected screen but a drawer that reads
|
||||||
|
* negative for the rest of its life: a settlement is subtracted from the cashier's position
|
||||||
|
* cumulatively, so one settlement of cash they never had leaves every later position short by
|
||||||
|
* that amount, on every date, with no transaction to point at. This database carries exactly
|
||||||
|
* that — ₹10.5 lakh settled against ₹26 thousand ever allocated, and a Cashier Position screen
|
||||||
|
* showing counts like -1814 as a result.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Checked per denomination, not on the total, for the same reason the vault side is: a drawer
|
||||||
|
* holding ₹600 in one ₹500 and one ₹100 cannot hand over six ₹100 notes, though the total
|
||||||
|
* agrees. The holding comes from {@code buildPositionForCashier} — the figure the Cashier
|
||||||
|
* Position screen shows — so what a cashier is told they hold is what they are allowed to settle.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A cashier with no position at all is left alone: a first settlement on a drawer whose
|
||||||
|
* allocations were recorded without a breakdown has nothing to check against, and blocking it
|
||||||
|
* would stop a branch settling at all rather than prevent a mistake.
|
||||||
|
*/
|
||||||
|
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
||||||
|
final LocalDate settlementDate) {
|
||||||
|
|
||||||
|
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
|
||||||
|
settlementDate.toString());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||||
|
if (heldRows == null || heldRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Long, Long> held = new java.util.HashMap<>();
|
||||||
|
for (final Map<String, Object> row : heldRows) {
|
||||||
|
final Object id = row.get("denominationId");
|
||||||
|
final Object count = row.get("count");
|
||||||
|
if (id instanceof Number && count instanceof Number) {
|
||||||
|
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final JsonElement el : denominations) {
|
||||||
|
final JsonObject item = el.getAsJsonObject();
|
||||||
|
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0;
|
||||||
|
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long denominationId = item.get("denominationId").getAsLong();
|
||||||
|
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||||
|
? item.get("denominationValue").getAsInt() : 0;
|
||||||
|
final long available = held.getOrDefault(denominationId, 0L);
|
||||||
|
if (count > available) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashsettlement.insufficient.cashier.denomination",
|
||||||
|
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
|
||||||
|
+ ", settling " + count + ". Check the Cashier Position report before settling.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean hasPositiveCount(JsonArray items) {
|
private boolean hasPositiveCount(JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
||||||
|
|||||||
+27
-30
@@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
||||||
@@ -52,6 +53,9 @@ public class CashTransferApiResource {
|
|||||||
private final PlatformTransactionManager transactionManager;
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
|
// The same source the Cash Transfer screen reads its Available column from, so what is offered
|
||||||
|
// and what is checked cannot disagree.
|
||||||
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -327,46 +331,39 @@ public class CashTransferApiResource {
|
|||||||
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
||||||
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
||||||
* total is well within its balance. This checks each requested denomination against what the
|
* total is well within its balance. This checks each requested denomination against what the
|
||||||
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
|
* vault actually holds of it.
|
||||||
* settlements-in minus today's transfers-out actually holds of that specific denomination.
|
|
||||||
*
|
*
|
||||||
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
|
* <p>
|
||||||
* denomination against, so this is skipped rather than blocking every transfer on a day that
|
* The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
|
||||||
* simply never captured one.
|
* shows in its Available column. It has to: this check used to walk the register itself and read
|
||||||
|
* the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under
|
||||||
|
* it is holding and is anchored on a different day. The screen offered a ₹100 note the vault
|
||||||
|
* plainly had and the transfer was then refused as "vault has 0" — two answers to one question,
|
||||||
|
* from two sources. Reading the service means the number that was offered is the number that is
|
||||||
|
* checked, and neither can drift from the other again.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A day with no breakdown recorded anywhere has nothing to check a denomination against, so this
|
||||||
|
* is skipped rather than blocking every transfer on a day that simply never captured one.
|
||||||
*/
|
*/
|
||||||
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
|
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
|
||||||
final JsonArray denominations, final java.sql.Date businessDate) {
|
final JsonArray denominations, final java.sql.Date businessDate) {
|
||||||
final List<Object[]> openingRows = jdbcTemplate.query("""
|
final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
|
||||||
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
|
if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
|
||||||
WHERE register_id = ? AND leg = 'OPENING'
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
|
|
||||||
if (openingRows.isEmpty()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<Long, Integer> available = new java.util.HashMap<>();
|
final Map<Long, Integer> available = new java.util.HashMap<>();
|
||||||
for (final Object[] row : openingRows) {
|
@SuppressWarnings("unchecked")
|
||||||
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
|
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||||
|
if (heldRows != null) {
|
||||||
|
for (final Map<String, Object> row : heldRows) {
|
||||||
|
final Object id = row.get("denominationId");
|
||||||
|
final Object count = row.get("count");
|
||||||
|
if (id instanceof Number && count instanceof Number) {
|
||||||
|
available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Object[]> settledRows = jdbcTemplate.query("""
|
|
||||||
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
|
|
||||||
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
|
||||||
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
|
||||||
GROUP BY csd.denomination_id
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
|
||||||
for (final Object[] row : settledRows) {
|
|
||||||
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Object[]> handedOutRows = jdbcTemplate.query("""
|
|
||||||
SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd
|
|
||||||
JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
|
||||||
WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
|
|
||||||
GROUP BY ctd.denomination_id
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
|
||||||
for (final Object[] row : handedOutRows) {
|
|
||||||
available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final JsonElement el : denominations) {
|
for (final JsonElement el : denominations) {
|
||||||
|
|||||||
+88
-1
@@ -1,6 +1,8 @@
|
|||||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
@@ -21,6 +23,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.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -239,7 +242,15 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
// account to look in is never guessed from product configuration — the counter selects it
|
// account to look in is never guessed from product configuration — the counter selects it
|
||||||
// by hand every time, since a product mapping can silently go missing or point at the
|
// by hand every time, since a product mapping can silently go missing or point at the
|
||||||
// wrong account and a plain voucher lookup should never depend on that being right.
|
// wrong account and a plain voucher lookup should never depend on that being right.
|
||||||
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
|
Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
|
||||||
|
if (adopted == null) {
|
||||||
|
// A member can also fund the same GL account through the Clerk's Disbursement Request
|
||||||
|
// register (a COLLECTION request debits the member's own savings account and credits a
|
||||||
|
// GL account, approved the same way a withdrawal is) — a second, equally legitimate way
|
||||||
|
// for money to reach Share Sundry / a fee-income account without a cashier counter
|
||||||
|
// deposit ever being raised.
|
||||||
|
adopted = adoptApprovedClerkDisbursementCollection(voucherNo, glAccountNoOverride);
|
||||||
|
}
|
||||||
if (adopted != null) {
|
if (adopted != null) {
|
||||||
return Response.ok(gson.toJson(adopted)).build();
|
return Response.ok(gson.toJson(adopted)).build();
|
||||||
}
|
}
|
||||||
@@ -637,6 +648,81 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
receiptNo);
|
receiptNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopts an approved Clerk Disbursement Request ("COLLECTION" direction) into the Share Sundry GL as a share
|
||||||
|
* advance collection.
|
||||||
|
*
|
||||||
|
* A COLLECTION request debits the member's own savings account and credits a GL account, approved by a manager
|
||||||
|
* the same way a clerk withdrawal is -- a second, equally legitimate way for a member's money to reach Share
|
||||||
|
* Sundry (or a fee-income account) besides a cashier counter deposit. The request itself carries no client id
|
||||||
|
* column; the member is only recorded, as JSON, on the debit leg of {@code credit_entries}.
|
||||||
|
*
|
||||||
|
* @param glAccountNoOverride
|
||||||
|
* required -- same contract as {@link #adoptApprovedDeposit}: the GL account the counter selected,
|
||||||
|
* never guessed from product configuration.
|
||||||
|
* @return the newly created collection row, or null when no approved COLLECTION request matches.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
|
||||||
|
final String glAccountNo = glAccountNoOverride.trim();
|
||||||
|
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
|
||||||
|
+ " FROM m_clerk_disbursement_request"
|
||||||
|
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
|
||||||
|
+ " ORDER BY id DESC",
|
||||||
|
voucherNo, glAccountNo);
|
||||||
|
if (requests.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Map<String, Object> req = requests.get(0);
|
||||||
|
|
||||||
|
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
|
||||||
|
if (savingsAccountId == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
||||||
|
"Voucher " + voucherNo + " does not debit a member's savings account, so it cannot be matched to a member.");
|
||||||
|
}
|
||||||
|
final Long clientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
||||||
|
savingsAccountId);
|
||||||
|
|
||||||
|
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(clientId);
|
||||||
|
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
|
||||||
|
final Object actionedAt = req.get("actioned_at");
|
||||||
|
final Object submittedAt = req.get("submitted_at");
|
||||||
|
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||||
|
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||||
|
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
|
||||||
|
final String receiptNo = nextReceiptNo();
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO m_share_advance_collection "
|
||||||
|
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||||
|
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||||
|
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
|
||||||
|
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForMap(
|
||||||
|
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||||
|
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
||||||
|
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||||
|
receiptNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The savings account id named on the debit ("Dr") leg of a Clerk Disbursement Request's {@code credit_entries}
|
||||||
|
* JSON array, or null when that leg names a GL account instead (no member to match against). */
|
||||||
|
private Long debitSavingsAccountId(final String creditEntriesJson) {
|
||||||
|
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final JsonArray entries = JsonParser.parseString(creditEntriesJson).getAsJsonArray();
|
||||||
|
for (final JsonElement el : entries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
final boolean isDebit = entry.has("dbCr") && "Dr".equalsIgnoreCase(entry.get("dbCr").getAsString());
|
||||||
|
if (isDebit && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||||
|
return entry.get("savingsAccountId").getAsLong();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private Long loadClientOfficeId(final Long clientId) {
|
private Long loadClientOfficeId(final Long clientId) {
|
||||||
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
|
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
|
||||||
}
|
}
|
||||||
@@ -751,6 +837,7 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
final String description) {
|
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, entity_id, "
|
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||||
|
|||||||
+2
@@ -21,6 +21,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.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
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;
|
||||||
@@ -360,6 +361,7 @@ public class ShareCollectionApiResource {
|
|||||||
final LocalDate entryDate, final Long shareAccountId,
|
final LocalDate entryDate, final Long shareAccountId,
|
||||||
final long userId, final LocalDateTime now, final String currencyCode,
|
final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
final String description) {
|
final String description) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"INSERT INTO acc_gl_journal_entry "
|
"INSERT INTO acc_gl_journal_entry "
|
||||||
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
|
|||||||
+53
-10
@@ -67,6 +67,7 @@ import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProces
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
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;
|
||||||
@@ -82,6 +83,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
public class ClerkDisbursementRequestApiResource {
|
public class ClerkDisbursementRequestApiResource {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -143,6 +145,14 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
||||||
throw apiError(400, "Each entry must have an account number");
|
throw apiError(400, "Each entry must have an account number");
|
||||||
}
|
}
|
||||||
|
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
|
||||||
|
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
|
||||||
|
// working, since nothing about a restricted customer should prevent them being paid.
|
||||||
|
// A GL leg carries no savingsAccountId and the guard ignores it.
|
||||||
|
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||||
|
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
|
||||||
|
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
|
||||||
|
}
|
||||||
if ("Dr".equals(dbCr)) {
|
if ("Dr".equals(dbCr)) {
|
||||||
totalDr = totalDr.add(entryAmt);
|
totalDr = totalDr.add(entryAmt);
|
||||||
} else {
|
} else {
|
||||||
@@ -187,12 +197,26 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
activityLog, Timestamp.from(Instant.now()), requestType
|
activityLog, Timestamp.from(Instant.now()), requestType
|
||||||
);
|
);
|
||||||
|
|
||||||
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
|
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
|
||||||
// bank transfer both move money out of the source side, so they read as a debit.
|
// money out of the source side, so it reads as a debit — but only when the TXN TP. the
|
||||||
final String voucherPrefix = "COLLECTION".equals(requestType)
|
// clerk actually picked is Cash. That dropdown is the real-world signal for whether notes
|
||||||
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
// cross the counter, the same way it already governs every teller screen in this system
|
||||||
|
// (isCashPayment): a clerk who selects Transfer, Cheque, DD or NEFT/RTGS is saying no cash
|
||||||
|
// moves for this request, whatever GL it happens to post against, so it belongs in the
|
||||||
|
// Journal series instead of being counted as real cash received or paid on My Vouchers.
|
||||||
|
final boolean isCashPayment = paymentTypeId != null && Boolean.TRUE.equals(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId));
|
||||||
|
final String voucherPrefix = !isCashPayment ? VoucherService.PREFIX_JOURNAL
|
||||||
|
: "COLLECTION".equals(requestType) ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
||||||
|
// The voucher's own TYPE column used to be hard-coded to VoucherService.TYPE_DISBURSEMENT
|
||||||
|
// for every request this register raises, so My Vouchers showed "DISBURSEMENT" even for a
|
||||||
|
// Collection or a Fund Transfer the clerk had explicitly picked a different TYPE for on the
|
||||||
|
// entry screen — the one place this register's actual four-way distinction ought to have
|
||||||
|
// been visible was throwing it away. Stored as the clerk's own requestType instead, in the
|
||||||
|
// same words the TYPE dropdown already uses.
|
||||||
|
final String voucherTypeLabel = voucherTypeLabelFor(requestType);
|
||||||
final String voucherNo = voucherService.createVoucher(
|
final String voucherNo = voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||||
newId, txnAmount, currentUser, voucherPrefix);
|
newId, txnAmount, currentUser, voucherPrefix);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||||
@@ -1010,13 +1034,26 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
||||||
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
|
* voucher so the two numbering schemes cannot drift apart.
|
||||||
* the debited accounts, so it reads as a debit: DR/2108/0001.
|
*
|
||||||
|
* Numbered Journal rather than Debit: both sides of a bank transfer are member accounts, and
|
||||||
|
* the money never touches Cash in Hand or the vault either way (see the "no CashVault entry
|
||||||
|
* created anywhere" comments where these accounts are debited/credited directly). A DR number
|
||||||
|
* would have read as real cash paid out, double-counting the figure in My Vouchers on top of
|
||||||
|
* the day's actual Payment total.
|
||||||
*/
|
*/
|
||||||
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
||||||
return voucherService.createVoucher(
|
return voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
|
"Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG,
|
||||||
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
|
logId, amount, currentUser, VoucherService.PREFIX_JOURNAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The TYPE column's label, in the same words the clerk's own TYPE dropdown uses for it. */
|
||||||
|
private static String voucherTypeLabelFor(final String requestType) {
|
||||||
|
if ("COLLECTION".equals(requestType)) return "Collection";
|
||||||
|
if ("BANK_TRANSFER".equals(requestType)) return "Bank Transfer";
|
||||||
|
if ("FUND_TRANSFER".equals(requestType)) return "Fund Transfer";
|
||||||
|
return "Disbursement";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1250,7 +1287,13 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
// This disbursement/collection request already raised its own voucher at submission and
|
||||||
|
// attaches every leg to it in addDisbursementVoucherLines() after approval succeeds — without
|
||||||
|
// withOwnVoucher() the plain savings withdrawal command below raises a second, orphaned
|
||||||
|
// voucher of its own for the same money (see VoucherService.withOwnVoucher's javadoc), which
|
||||||
|
// is what was double-counting a Collection's amount in both Received and Payments on My Vouchers.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-16
@@ -49,6 +49,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -76,10 +77,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* Close Savings Account screen), and a cashier hands over the physical cash against the token,
|
* Close Savings Account screen), and a cashier hands over the physical cash against the token,
|
||||||
* recording the denomination breakdown.
|
* recording the denomination breakdown.
|
||||||
*
|
*
|
||||||
* Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the
|
* Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded DOWN
|
||||||
* nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated
|
* to the whole rupee for the physical hand-over -- 9,883.79 is paid as 9,883 -- and the dropped paise
|
||||||
* "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own
|
* are absorbed by the Rounding Adjustment account the savings product names, via one small adjusting
|
||||||
* ledger (which stays exactly correct to the paisa).
|
* journal entry. The member's own ledger stays exactly correct to the paisa.
|
||||||
|
*
|
||||||
|
* Down rather than to the nearest rupee, so the branch never pays out more cash than the account
|
||||||
|
* actually held: rounding 9,883.79 up to 9,884 hands over 21 paise the member never had.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/clerk/closure-requests")
|
@Path("/v1/clerk/closure-requests")
|
||||||
@Component
|
@Component
|
||||||
@@ -91,6 +95,13 @@ public class ClerkClosureRequestApiResource {
|
|||||||
// and a field of the same name reads as that variable at a glance.
|
// and a field of the same name reads as that variable at a glance.
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class);
|
||||||
|
|
||||||
|
/** acc_product_mapping.product_type for a savings product. */
|
||||||
|
private static final int PRODUCT_TYPE_SAVING = 2;
|
||||||
|
/** {@code CashAccountsForSavings.ROUNDING_ADJUSTMENT} */
|
||||||
|
private static final int ROUNDING_ADJUSTMENT_ACCOUNT_TYPE = 26;
|
||||||
|
/** Where the paise go on a product that has no mapping of its own yet. */
|
||||||
|
private static final String DEFAULT_ROUND_OFF_GL_CODE = "501200";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -170,7 +181,7 @@ public class ClerkClosureRequestApiResource {
|
|||||||
final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate);
|
final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate);
|
||||||
// Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING,
|
// Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING,
|
||||||
// exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure.
|
// exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure.
|
||||||
final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP);
|
final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR);
|
||||||
final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
|
final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
@@ -215,6 +226,31 @@ public class ClerkClosureRequestApiResource {
|
|||||||
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
|
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
|
||||||
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
||||||
savingsAccountWritePlatformService.postInterest(account, true, closureDate, false);
|
savingsAccountWritePlatformService.postInterest(account, true, closureDate, false);
|
||||||
|
|
||||||
|
// What the account itself says is still owed once that posting has run: everything the
|
||||||
|
// account has earned over its life, less everything already credited to the member. The
|
||||||
|
// account's own summary is the figure the account screen shows, so a closure and that screen
|
||||||
|
// can no longer disagree about what is outstanding.
|
||||||
|
//
|
||||||
|
// Normally this is zero -- the posting above has just credited the shortfall, which is the
|
||||||
|
// whole point of running it. It is not zero when the summary carries interest the posting did
|
||||||
|
// not reach: interest accrued against a period the posting run does not close, most often on
|
||||||
|
// a cycle that has not yet ended. Left alone that interest is simply dropped, because the
|
||||||
|
// account is about to be closed and no later run will ever pay it.
|
||||||
|
//
|
||||||
|
// Only ever pays out what the two totals still differ by, so interest already credited cannot
|
||||||
|
// be paid a second time, and a negative difference (more posted than earned, which a reversal
|
||||||
|
// can leave behind) is ignored rather than taken back off the member here.
|
||||||
|
final BigDecimal earned = zeroIfNull(account.getSummary().getTotalInterestEarned());
|
||||||
|
final BigDecimal posted = zeroIfNull(account.getSummary().getTotalInterestPosted());
|
||||||
|
final BigDecimal stillOwed = earned.subtract(posted);
|
||||||
|
|
||||||
|
if (stillOwed.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
LOG.warn("Closure of savings account {}: {} of accrued interest is still unposted after posting up to {} "
|
||||||
|
+ "(earned {} against posted {}). The payout below does not include it.", savingsAccountId, stillOwed,
|
||||||
|
closureDate, earned, posted);
|
||||||
|
}
|
||||||
|
|
||||||
return zeroIfNull(account.getSummary().getAccountBalance());
|
return zeroIfNull(account.getSummary().getAccountBalance());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +327,7 @@ public class ClerkClosureRequestApiResource {
|
|||||||
res.addProperty("total", total);
|
res.addProperty("total", total);
|
||||||
// What a cashier could physically count out: no paisa coins circulate, so the hand-over is
|
// What a cashier could physically count out: no paisa coins circulate, so the hand-over is
|
||||||
// always whole rupees (the difference lands on the Round Off Account at approval).
|
// always whole rupees (the difference lands on the Round Off Account at approval).
|
||||||
res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP));
|
res.addProperty("roundedTotal", total.setScale(0, RoundingMode.FLOOR));
|
||||||
res.addProperty("interestUpToDate", true);
|
res.addProperty("interestUpToDate", true);
|
||||||
res.addProperty("asOfDate", closureDate.toString());
|
res.addProperty("asOfDate", closureDate.toString());
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
@@ -509,12 +545,12 @@ public class ClerkClosureRequestApiResource {
|
|||||||
if (txn != null) {
|
if (txn != null) {
|
||||||
txnId = txn.get("id").toString();
|
txnId = txn.get("id").toString();
|
||||||
closingAmount = new BigDecimal(txn.get("amount").toString());
|
closingAmount = new BigDecimal(txn.get("amount").toString());
|
||||||
cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP);
|
cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR);
|
||||||
roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
|
roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
|
||||||
|
|
||||||
if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) {
|
if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) {
|
||||||
final Long officeId = ((Number) txn.get("office_id")).longValue();
|
final Long officeId = ((Number) txn.get("office_id")).longValue();
|
||||||
postRoundingAdjustment(officeId, roundingAmount, currentUser);
|
postRoundingAdjustment(officeId, roundingAmount, currentUser, savingsAccountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,11 +806,11 @@ public class ClerkClosureRequestApiResource {
|
|||||||
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
|
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
|
||||||
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
|
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
|
||||||
if (roundingDirection > 0) {
|
if (roundingDirection > 0) {
|
||||||
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs()));
|
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
|
||||||
}
|
}
|
||||||
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
|
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
|
||||||
if (roundingDirection < 0) {
|
if (roundingDirection < 0) {
|
||||||
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs()));
|
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
|
||||||
}
|
}
|
||||||
|
|
||||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
|
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
|
||||||
@@ -799,9 +835,10 @@ public class ClerkClosureRequestApiResource {
|
|||||||
* extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the
|
* extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the
|
||||||
* difference as an expense). Rounded down, both legs flip.
|
* difference as an expense). Rounded down, both legs flip.
|
||||||
*/
|
*/
|
||||||
private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) {
|
private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser,
|
||||||
|
final Long savingsAccountId) {
|
||||||
final Map<String, Object> cashGl = resolveCashGlAccount();
|
final Map<String, Object> cashGl = resolveCashGlAccount();
|
||||||
final Long roundOffGlId = resolveRoundOffGlAccount();
|
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
|
||||||
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 BigDecimal amount = roundingAmount.abs();
|
final BigDecimal amount = roundingAmount.abs();
|
||||||
@@ -814,10 +851,15 @@ public class ClerkClosureRequestApiResource {
|
|||||||
// type_enum: 2 = DEBIT, 1 = CREDIT.
|
// type_enum: 2 = DEBIT, 1 = CREDIT.
|
||||||
if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) {
|
if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
// Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash
|
// Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash
|
||||||
|
//
|
||||||
|
// Unreachable while the hand-over rounds down, and kept so: an amount already whole to
|
||||||
|
// the rupee produces no adjustment at all, and if the rounding rule is ever changed
|
||||||
|
// back this leg is still correct rather than silently posting the wrong way.
|
||||||
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
|
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
|
||||||
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
|
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
|
||||||
} else {
|
} else {
|
||||||
// Rounded down: less cash left the drawer than the member's ledger gave up.
|
// Rounded down, which is now every case: the member's ledger gave up the full paisa
|
||||||
|
// amount but less cash left the drawer, so the paise stay with the bank.
|
||||||
// Dr Cash / Cr Round Off
|
// Dr Cash / Cr Round Off
|
||||||
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
|
insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser);
|
||||||
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
|
insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser);
|
||||||
@@ -837,11 +879,39 @@ public class ClerkClosureRequestApiResource {
|
|||||||
return rows.get(0);
|
return rows.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long resolveRoundOffGlAccount() {
|
/**
|
||||||
|
* The GL the dropped paise are absorbed by, from the closing account's own savings product.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read from the product's Accounting tab like every other GL this closure touches, so a bank
|
||||||
|
* on its own chart of accounts posts to the head it chose. This used to be
|
||||||
|
* {@code gl_code = '501200'} written into the query, which meant a tenant whose chart has no
|
||||||
|
* such code could not close an account at all, and a bank that keeps its round-off elsewhere
|
||||||
|
* had to create a 501200 anyway.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Falls back to that code where the product has no mapping yet — every product created before
|
||||||
|
* the row existed is in that position, and a closure must not fail over a few paise.
|
||||||
|
*/
|
||||||
|
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
|
||||||
|
if (savingsAccountId != null) {
|
||||||
|
final List<Long> mapped = jdbcTemplate.queryForList("""
|
||||||
|
SELECT m.gl_account_id
|
||||||
|
FROM acc_product_mapping m
|
||||||
|
JOIN m_savings_account sa ON sa.product_id = m.product_id
|
||||||
|
WHERE sa.id = ? AND m.product_type = ? AND m.financial_account_type = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, savingsAccountId, PRODUCT_TYPE_SAVING, ROUNDING_ADJUSTMENT_ACCOUNT_TYPE);
|
||||||
|
if (!mapped.isEmpty()) {
|
||||||
|
return mapped.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
final List<Long> ids = jdbcTemplate.queryForList(
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
"SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class);
|
"SELECT id FROM acc_gl_account WHERE gl_code = ? LIMIT 1", Long.class, DEFAULT_ROUND_OFF_GL_CODE);
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet.");
|
throw apiError(500, "No Rounding Adjustment account is mapped on the savings product, and the default "
|
||||||
|
+ "Round Off Account (gl_code " + DEFAULT_ROUND_OFF_GL_CODE + ") does not exist in this tenant. "
|
||||||
|
+ "Map one on the product's Accounting tab.");
|
||||||
}
|
}
|
||||||
return ids.get(0);
|
return ids.get(0);
|
||||||
}
|
}
|
||||||
@@ -850,6 +920,7 @@ public class ClerkClosureRequestApiResource {
|
|||||||
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,
|
||||||
|
|||||||
+73
@@ -49,6 +49,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -56,7 +57,11 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
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.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -77,10 +82,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClerkShareWithdrawalRequestApiResource {
|
public class ClerkShareWithdrawalRequestApiResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||||
@@ -701,9 +709,17 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
// shares left for the native close command to force-redeem without oversight.
|
// shares left for the native close command to force-redeem without oversight.
|
||||||
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||||
|
|
||||||
|
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
|
||||||
|
// redemption settled into a member's savings account left nothing on the voucher register to
|
||||||
|
// read it back from. The GL entries were always written — it is the slip that was missing.
|
||||||
|
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
res.addProperty("resourceId", id);
|
res.addProperty("resourceId", id);
|
||||||
res.addProperty("status", "APPROVED");
|
res.addProperty("status", "APPROVED");
|
||||||
|
if (voucherNo != null) {
|
||||||
|
res.addProperty("voucherNo", voucherNo);
|
||||||
|
}
|
||||||
if (shareTxnId != null) {
|
if (shareTxnId != null) {
|
||||||
res.addProperty("shareTransactionId", shareTxnId);
|
res.addProperty("shareTransactionId", shareTxnId);
|
||||||
}
|
}
|
||||||
@@ -742,6 +758,62 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Mirrors the GL pair {@link #creditLinkedSavingsAccount} posts, so the slip and the ledger say
|
||||||
|
* the same thing. A redemption with no linked savings account settles nothing yet and raises no
|
||||||
|
* voucher — there is no second leg to name until somebody settles it by hand.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A failure here is logged and swallowed, never rethrown: the redemption, its GL entries and the
|
||||||
|
* savings credit have all already succeeded in this transaction, and losing the slip is worth
|
||||||
|
* less than rolling back a member's money.
|
||||||
|
*/
|
||||||
|
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
|
||||||
|
final Settlement settlement, final AppUser currentUser) {
|
||||||
|
|
||||||
|
if (!voucherService.shouldRaiseVoucher() || settlement == null
|
||||||
|
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
||||||
|
shareAccountId);
|
||||||
|
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||||
|
|
||||||
|
final Long savingsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
|
||||||
|
|
||||||
|
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
|
||||||
|
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
|
||||||
|
final String savingsAccountNo = savingsAccountId == null ? null
|
||||||
|
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
|
||||||
|
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
|
||||||
|
|
||||||
|
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
|
||||||
|
// savings account, which is the receipt series — the share account is the source, not a
|
||||||
|
// counter payment.
|
||||||
|
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT,
|
||||||
|
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
|
||||||
|
if (settlement.savingsTransactionId() != null) {
|
||||||
|
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
|
||||||
|
settlement.savingsTransactionId());
|
||||||
|
}
|
||||||
|
return voucherNo;
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -990,6 +1062,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
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 shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
final String description) {
|
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, entity_id, "
|
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||||
|
|||||||
+9
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
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.portfolio.client.domain.KycExpiryStage;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -77,6 +79,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -121,6 +124,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||||
}
|
}
|
||||||
validateTokenUniqueToday(tokNo, null);
|
validateTokenUniqueToday(tokNo, null);
|
||||||
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
|
|
||||||
@@ -453,6 +457,11 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
|
|
||||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||||
|
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
||||||
|
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
||||||
|
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
||||||
|
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
||||||
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||||
final Object ptId = record.get("payment_type_id");
|
final Object ptId = record.get("payment_type_id");
|
||||||
final Object txnDateObj = record.get("txn_date");
|
final Object txnDateObj = record.get("txn_date");
|
||||||
|
|||||||
+155
-81
@@ -25,13 +25,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.HeaderParam;
|
import jakarta.ws.rs.HeaderParam;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.math.BigDecimal;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -47,28 +47,44 @@ import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredential
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single client's full deposit + share picture, looked up by client number OR mobile number (the
|
* A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share
|
||||||
* path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather
|
* accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number
|
||||||
* than the internal client id -- these are what a counter clerk has on the slip, or what a third-party
|
* (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no})
|
||||||
* caller was given, respectively, so no separate client-search step is needed before this call. Built
|
* rather than the internal client id -- these are what a counter clerk has on the slip, or what a
|
||||||
* so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only
|
* third-party caller was given, respectively, so no separate client-search step is needed before this
|
||||||
* queries, on the theory that this is exactly the kind of check a teller or manager runs right before
|
* call. Built so the whole thing, including the client lookup itself, resolves in a small handful of
|
||||||
* approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here"
|
* index-scan-only queries.
|
||||||
* question this endpoint answers has to come back before that decision is made, not after.
|
*
|
||||||
|
* Deliberately narrow on purpose:
|
||||||
|
* <ul>
|
||||||
|
* <li>Only ACTIVE accounts (status_enum 300) are returned -- a closed, pending, or rejected account is
|
||||||
|
* not part of "what does this client currently hold", which is the question this endpoint answers.</li>
|
||||||
|
* <li>No balance/currentValue/onHoldFunds figures at all, for any account type -- this is an account
|
||||||
|
* listing, not a balance enquiry; a caller that needs the money figure is expected to hit the
|
||||||
|
* account-specific balance endpoint for it (kept as a smaller, separate, cacheable concern rather than
|
||||||
|
* mixed into every listing response).</li>
|
||||||
|
* <li>Share accounts are excluded entirely -- they carry no balance in the deposit sense (approved
|
||||||
|
* shares x unit price is a valuation, not a balance) and don't belong in a "deposit accounts" listing.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations
|
||||||
|
* 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see
|
||||||
|
* {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank
|
||||||
|
* is this client's account actually at" (for a passbook header, a cheque book request, a KYC document)
|
||||||
|
* would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and
|
||||||
|
* {@code /v1/institutionprofile}.
|
||||||
*
|
*
|
||||||
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
|
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
|
||||||
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
|
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
|
||||||
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
|
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
|
||||||
* caller doesn't need -- account number, product, status and the current balance/share value is the
|
* caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different
|
||||||
* whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query
|
* query engine.
|
||||||
* engine.
|
|
||||||
*
|
*
|
||||||
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
|
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
|
||||||
* they do every other endpoint; a third-party integration that should never see a Fineract username or
|
* they do every other endpoint; a third-party integration that should never see a Fineract username or
|
||||||
@@ -80,7 +96,7 @@ import org.springframework.stereotype.Component;
|
|||||||
*/
|
*/
|
||||||
@Path("/v1/clientaccounts")
|
@Path("/v1/clientaccounts")
|
||||||
@Component
|
@Component
|
||||||
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.")
|
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClientAllAccountsApiResource {
|
public class ClientAllAccountsApiResource {
|
||||||
|
|
||||||
@@ -95,44 +111,56 @@ public class ClientAllAccountsApiResource {
|
|||||||
// that unrelated feature toggle is on.
|
// that unrelated feature toggle is on.
|
||||||
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
||||||
|
|
||||||
// One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum),
|
// ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses
|
||||||
// CD (m_current_account, its own table -- current accounts were never folded into
|
// the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the
|
||||||
// m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same
|
// same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD
|
||||||
// split), and Share (m_share_account). Every leg filters on client_id, which is indexed on
|
// (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table --
|
||||||
// all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an
|
// current accounts were never folded into m_savings_account here). Every leg filters on
|
||||||
|
// client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- an
|
||||||
// index-only lookup per leg, not a scan.
|
// index-only lookup per leg, not a scan.
|
||||||
private static final String ACCOUNTS_SQL = """
|
private static final String ACCOUNTS_SQL = """
|
||||||
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
|
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
|
||||||
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
|
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
|
||||||
p.name AS product_name, p.short_name AS short_product_name,
|
p.name AS product_name, p.short_name AS short_product_name,
|
||||||
sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold,
|
|
||||||
NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price,
|
|
||||||
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
|
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
|
||||||
FROM m_savings_account sa
|
FROM m_savings_account sa
|
||||||
JOIN m_savings_product p ON p.id = sa.product_id
|
JOIN m_savings_product p ON p.id = sa.product_id
|
||||||
WHERE sa.client_id = ?
|
WHERE sa.client_id = ? AND sa.status_enum = 300
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
|
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
|
||||||
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
|
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
|
||||||
ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0),
|
|
||||||
NULL::bigint, NULL::bigint, NULL::numeric(10,2),
|
|
||||||
ca.submittedon_date, ca.activatedon_date
|
ca.submittedon_date, ca.activatedon_date
|
||||||
FROM m_current_account ca
|
FROM m_current_account ca
|
||||||
JOIN m_current_product cp ON cp.id = ca.product_id
|
JOIN m_current_product cp ON cp.id = ca.product_id
|
||||||
WHERE ca.client_id = ?
|
WHERE ca.client_id = ? AND ca.status_enum = 300
|
||||||
|
""";
|
||||||
|
|
||||||
UNION ALL
|
// Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a
|
||||||
|
// caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook
|
||||||
|
// header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet
|
||||||
|
// (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields
|
||||||
|
// just come back null.
|
||||||
|
private static final String BRANCH_SQL = """
|
||||||
|
SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address,
|
||||||
|
p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email
|
||||||
|
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
|
||||||
|
WHERE o.id = ?
|
||||||
|
""";
|
||||||
|
|
||||||
SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id,
|
// The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row,
|
||||||
shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name,
|
// id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a
|
||||||
NULL::numeric(19,6), NULL::numeric(19,6),
|
// payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's
|
||||||
shr.total_approved_shares, shr.total_pending_shares, sp.unit_price,
|
// IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative
|
||||||
shr.submitted_date, shr.activated_date
|
// block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and
|
||||||
FROM m_share_account shr
|
// the created/updated audit columns, which are an internal concern of the Bank Profile screen, not
|
||||||
JOIN m_share_product sp ON sp.id = shr.product_id
|
// this endpoint's caller.
|
||||||
WHERE shr.client_id = ?
|
private static final String BANK_SQL = """
|
||||||
|
SELECT bank_name, legal_name, short_name, bank_code, swift_bic,
|
||||||
|
address_line1, address_line2, city, district, state, pincode,
|
||||||
|
contact_phone, contact_email, customer_care_number, website
|
||||||
|
FROM m_institution_profile WHERE id = 1
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -156,21 +184,17 @@ public class ClientAllAccountsApiResource {
|
|||||||
final Map<String, Object> client = resolveClient(identifier, tenant);
|
final Map<String, Object> client = resolveClient(identifier, tenant);
|
||||||
final Long clientId = ((Number) client.get("id")).longValue();
|
final Long clientId = ((Number) client.get("id")).longValue();
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId);
|
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId);
|
||||||
|
|
||||||
final Map<String, Object> totalsByCurrency = new LinkedHashMap<>();
|
|
||||||
final Map<String, Integer> countByType = new LinkedHashMap<>();
|
final Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||||
for (final Map<String, Object> row : rows) {
|
for (final Map<String, Object> row : rows) {
|
||||||
final String accountType = (String) row.get("accountType");
|
final String accountType = (String) row.get("accountType");
|
||||||
countByType.merge(accountType, 1, Integer::sum);
|
countByType.merge(accountType, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
final Object valueObj = row.get("currentValue");
|
final Long officeId = ((Number) client.get("office_id")).longValue();
|
||||||
if (valueObj instanceof BigDecimal value) {
|
final Map<String, Object> branch = resolveBranch(officeId);
|
||||||
final String currency = (String) row.get("currencyCode");
|
final Map<String, Object> bank = resolveBank();
|
||||||
final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO);
|
|
||||||
totalsByCurrency.put(currency, running.add(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
// Which bank this response came from -- not decorative. One API key now authenticates
|
// Which bank this response came from -- not decorative. One API key now authenticates
|
||||||
@@ -187,15 +211,71 @@ public class ClientAllAccountsApiResource {
|
|||||||
res.addProperty("clientNo", (String) client.get("account_no"));
|
res.addProperty("clientNo", (String) client.get("account_no"));
|
||||||
res.addProperty("clientName", (String) client.get("display_name"));
|
res.addProperty("clientName", (String) client.get("display_name"));
|
||||||
res.addProperty("mobileNo", (String) client.get("mobile_no"));
|
res.addProperty("mobileNo", (String) client.get("mobile_no"));
|
||||||
res.addProperty("officeId", ((Number) client.get("office_id")).longValue());
|
res.addProperty("officeId", officeId);
|
||||||
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
|
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
|
||||||
|
res.add("branch", gson.toJsonTree(branch));
|
||||||
|
res.add("bank", gson.toJsonTree(bank));
|
||||||
res.addProperty("totalAccounts", rows.size());
|
res.addProperty("totalAccounts", rows.size());
|
||||||
res.add("accountsByType", gson.toJsonTree(countByType));
|
res.add("accountsByType", gson.toJsonTree(countByType));
|
||||||
res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency));
|
|
||||||
res.add("accounts", gson.toJsonTree(rows));
|
res.add("accounts", gson.toJsonTree(rows));
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An office with no {@code m_office_profile} row yet (see
|
||||||
|
* {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary
|
||||||
|
* state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than
|
||||||
|
* this call failing or being skipped.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> resolveBranch(final Long officeId) {
|
||||||
|
final Map<String, Object> row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId);
|
||||||
|
final Map<String, Object> branch = new LinkedHashMap<>();
|
||||||
|
branch.put("officeId", officeId);
|
||||||
|
branch.put("officeName", row.get("office_name"));
|
||||||
|
branch.put("branchCode", row.get("branch_code"));
|
||||||
|
branch.put("branchType", row.get("branch_type"));
|
||||||
|
branch.put("address", row.get("address"));
|
||||||
|
branch.put("pincode", row.get("pincode"));
|
||||||
|
branch.put("ifscCode", row.get("ifsc_code"));
|
||||||
|
branch.put("micrCode", row.get("micr_code"));
|
||||||
|
branch.put("contactPhone", row.get("contact_phone"));
|
||||||
|
branch.put("contactEmail", row.get("contact_email"));
|
||||||
|
return branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bank running this tenant is a singleton row (id=1) that only gets created the first time
|
||||||
|
* someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists}
|
||||||
|
* -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened
|
||||||
|
* that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code
|
||||||
|
* null} in the response rather than this call failing.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> resolveBank() {
|
||||||
|
final Map<String, Object> row;
|
||||||
|
try {
|
||||||
|
row = jdbcTemplate.queryForMap(BANK_SQL);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Map<String, Object> bank = new LinkedHashMap<>();
|
||||||
|
bank.put("bankName", row.get("bank_name"));
|
||||||
|
bank.put("legalName", row.get("legal_name"));
|
||||||
|
bank.put("shortName", row.get("short_name"));
|
||||||
|
bank.put("bankCode", row.get("bank_code"));
|
||||||
|
bank.put("swiftBic", row.get("swift_bic"));
|
||||||
|
bank.put("addressLine1", row.get("address_line1"));
|
||||||
|
bank.put("addressLine2", row.get("address_line2"));
|
||||||
|
bank.put("city", row.get("city"));
|
||||||
|
bank.put("district", row.get("district"));
|
||||||
|
bank.put("state", row.get("state"));
|
||||||
|
bank.put("pincode", row.get("pincode"));
|
||||||
|
bank.put("contactPhone", row.get("contact_phone"));
|
||||||
|
bank.put("contactEmail", row.get("contact_email"));
|
||||||
|
bank.put("customerCareNumber", row.get("customer_care_number"));
|
||||||
|
bank.put("website", row.get("website"));
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
|
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
|
||||||
* the worst case, one in the common case, both on unique indexed columns
|
* the worst case, one in the common case, both on unique indexed columns
|
||||||
@@ -234,11 +314,33 @@ public class ClientAllAccountsApiResource {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '"
|
throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '"
|
||||||
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
|
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
|
||||||
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
|
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*}
|
||||||
|
* exception: {@code NotFoundException(String)} only sets the Java exception's own message (for
|
||||||
|
* logs), it never puts that text on the wire -- no exception mapper in this app converts a plain
|
||||||
|
* NotFoundException's message into the response body, so the caller always got
|
||||||
|
* {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message
|
||||||
|
* passed to the constructor was.
|
||||||
|
*
|
||||||
|
* One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0]
|
||||||
|
* .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape
|
||||||
|
* exists there to line up with Fineract's own multi-field validation errors, where {@code errors}
|
||||||
|
* really does carry one entry per invalid parameter. This endpoint never raises more than one
|
||||||
|
* error at a time, so the same sentence repeated three times said nothing extra, only made the
|
||||||
|
* body longer.
|
||||||
|
*/
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("httpStatusCode", httpStatus);
|
||||||
|
body.addProperty("message", message);
|
||||||
|
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveClientIp(final HttpServletRequest request) {
|
private String resolveClientIp(final HttpServletRequest request) {
|
||||||
for (final String header : IP_HEADER_CANDIDATES) {
|
for (final String header : IP_HEADER_CANDIDATES) {
|
||||||
final String value = request.getHeader(header);
|
final String value = request.getHeader(header);
|
||||||
@@ -252,27 +354,19 @@ public class ClientAllAccountsApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a
|
* Maps one UNION leg row into a flat, display-ready shape -- category label and human status only.
|
||||||
* single {@code currentValue} figure so the caller never has to know that a deposit's value lives
|
* No balance figure of any kind: this is an account listing, not a balance enquiry (see the class
|
||||||
* in one column and a share account's lives in another (approved shares × unit price).
|
* Javadoc).
|
||||||
*/
|
*/
|
||||||
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
|
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type");
|
final int depositType = rs.getInt("deposit_type");
|
||||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
|
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
|
||||||
final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance");
|
|
||||||
final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold");
|
|
||||||
final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares");
|
|
||||||
final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares");
|
|
||||||
final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price");
|
|
||||||
|
|
||||||
final boolean isShare = depositType == null;
|
|
||||||
final String accountType = isShare ? "Share Account" : accountTypeFor(depositType);
|
|
||||||
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("accountType", accountType);
|
row.put("accountType", accountTypeFor(depositType));
|
||||||
row.put("accountId", rs.getLong("account_id"));
|
row.put("accountId", rs.getLong("account_id"));
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
row.put("accountNo", rs.getString("account_no"));
|
||||||
row.put("externalId", rs.getString("external_id"));
|
row.put("externalId", rs.getString("external_id"));
|
||||||
@@ -288,28 +382,8 @@ public class ClientAllAccountsApiResource {
|
|||||||
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
|
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
|
||||||
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
|
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
|
||||||
|
|
||||||
if (isShare) {
|
|
||||||
final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum);
|
|
||||||
row.put("status", status);
|
|
||||||
row.put("approvedShares", approvedShares);
|
|
||||||
row.put("pendingShares", pendingShares);
|
|
||||||
row.put("unitPrice", unitPrice);
|
|
||||||
final BigDecimal shareValue = (approvedShares != null && unitPrice != null)
|
|
||||||
? unitPrice.multiply(BigDecimal.valueOf(approvedShares))
|
|
||||||
: null;
|
|
||||||
row.put("currentValue", shareValue);
|
|
||||||
} else {
|
|
||||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||||
row.put("status", status);
|
row.put("status", status);
|
||||||
row.put("balance", balance);
|
|
||||||
row.put("onHoldFunds", onHold);
|
|
||||||
BigDecimal available = balance;
|
|
||||||
if (available != null && onHold != null) {
|
|
||||||
available = available.subtract(onHold);
|
|
||||||
}
|
|
||||||
row.put("availableBalance", available);
|
|
||||||
row.put("currentValue", balance);
|
|
||||||
}
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -27,6 +27,10 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
|
|
||||||
private final Long id;
|
private final Long id;
|
||||||
private final String ruleName;
|
private final String ruleName;
|
||||||
|
/** "KYC" (profile-driven, categorises at onboarding) or "AML" (transaction-monitoring
|
||||||
|
* scenario). The client risk calculator evaluates KYC rules only - an AML rule describes
|
||||||
|
* behaviour and may only fire against a posted transaction. */
|
||||||
|
private final String ruleKind;
|
||||||
private final Integer legalFormId;
|
private final Integer legalFormId;
|
||||||
private final Long occupationCategoryCvId;
|
private final Long occupationCategoryCvId;
|
||||||
private final String occupationCategoryName;
|
private final String occupationCategoryName;
|
||||||
@@ -61,7 +65,7 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
private final BigDecimal txnMinLimitAmount;
|
private final BigDecimal txnMinLimitAmount;
|
||||||
private final BigDecimal txnMaxLimitAmount;
|
private final BigDecimal txnMaxLimitAmount;
|
||||||
|
|
||||||
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
final Long occupationCvId, final String occupationName,
|
final Long occupationCvId, final String occupationName,
|
||||||
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
|
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
|
||||||
@@ -75,6 +79,7 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
|
this.ruleKind = ruleKind;
|
||||||
this.legalFormId = legalFormId;
|
this.legalFormId = legalFormId;
|
||||||
this.occupationCategoryCvId = occupationCategoryCvId;
|
this.occupationCategoryCvId = occupationCategoryCvId;
|
||||||
this.occupationCategoryName = occupationCategoryName;
|
this.occupationCategoryName = occupationCategoryName;
|
||||||
@@ -111,6 +116,10 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
return ruleName;
|
return ruleName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRuleKind() {
|
||||||
|
return ruleKind;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getLegalFormId() {
|
public Integer getLegalFormId() {
|
||||||
return legalFormId;
|
return legalFormId;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -40,6 +40,12 @@ public class KycRiskRuleRequest {
|
|||||||
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String ruleName;
|
private String ruleName;
|
||||||
|
|
||||||
|
@Schema(description = "Which family the rule belongs to: KYC for a profile rule that categorises a customer at "
|
||||||
|
+ "onboarding from occupation / income / turnover, or AML for a transaction-monitoring scenario that may "
|
||||||
|
+ "only fire against posted transactions. Omit to have it inferred - supplying transaction settings, or "
|
||||||
|
+ "naming no profile criteria at all, makes it AML.", example = "KYC")
|
||||||
|
private String ruleKind;
|
||||||
|
|
||||||
@Schema(description = "Reason / criteria text explaining why the rule exists",
|
@Schema(description = "Reason / criteria text explaining why the rule exists",
|
||||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String reason;
|
private String reason;
|
||||||
|
|||||||
+82
-5
@@ -52,6 +52,8 @@ import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTrans
|
|||||||
import org.apache.fineract.portfolio.client.kyc.KycVerificationResult;
|
import org.apache.fineract.portfolio.client.kyc.KycVerificationResult;
|
||||||
import org.apache.fineract.portfolio.client.kyc.KycVerificationService;
|
import org.apache.fineract.portfolio.client.kyc.KycVerificationService;
|
||||||
import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer;
|
import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
@@ -73,6 +75,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
private final KycVerificationService kycVerificationService;
|
private final KycVerificationService kycVerificationService;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
|
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
|
||||||
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
|
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
|
||||||
|
|
||||||
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
|
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
|
||||||
@@ -414,8 +417,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
getJsonString(panData, "aadhaar_seeding_status"),
|
getJsonString(panData, "aadhaar_seeding_status"),
|
||||||
getJsonString(panData, "category"),
|
getJsonString(panData, "category"),
|
||||||
getJsonString(panData, "status"));
|
getJsonString(panData, "status"));
|
||||||
kyc.tryAutoVerify();
|
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||||
|
// same one the explicit command clears.
|
||||||
|
if (fullyVerified) {
|
||||||
|
reviveDormantAccounts(clientId);
|
||||||
|
}
|
||||||
LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue());
|
LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue());
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
@@ -458,8 +466,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
}
|
}
|
||||||
|
|
||||||
kyc.markAadhaarVerified();
|
kyc.markAadhaarVerified();
|
||||||
kyc.tryAutoVerify();
|
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||||
|
// same one the explicit command clears.
|
||||||
|
if (fullyVerified) {
|
||||||
|
reviveDormantAccounts(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("aadhaarVerified", true);
|
changes.put("aadhaarVerified", true);
|
||||||
@@ -500,8 +513,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
}
|
}
|
||||||
|
|
||||||
kyc.markGstinVerified();
|
kyc.markGstinVerified();
|
||||||
kyc.tryAutoVerify();
|
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||||
|
// same one the explicit command clears.
|
||||||
|
if (fullyVerified) {
|
||||||
|
reviveDormantAccounts(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("gstinVerified", true);
|
changes.put("gstinVerified", true);
|
||||||
@@ -563,8 +581,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
}
|
}
|
||||||
|
|
||||||
kyc.markCinVerified();
|
kyc.markCinVerified();
|
||||||
kyc.tryAutoVerify();
|
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||||
|
// same one the explicit command clears.
|
||||||
|
if (fullyVerified) {
|
||||||
|
reviveDormantAccounts(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("cinVerified", true);
|
changes.put("cinVerified", true);
|
||||||
@@ -633,8 +656,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final boolean companyNameMatched = matchedCompany != null;
|
final boolean companyNameMatched = matchedCompany != null;
|
||||||
|
|
||||||
kyc.markDinVerified(dinNumber, rawMcaResponse);
|
kyc.markDinVerified(dinNumber, rawMcaResponse);
|
||||||
kyc.tryAutoVerify();
|
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||||
|
// same one the explicit command clears.
|
||||||
|
if (fullyVerified) {
|
||||||
|
reviveDormantAccounts(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("dinNumber", dinNumber);
|
changes.put("dinNumber", dinNumber);
|
||||||
@@ -817,11 +845,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
kyc.markVerified(kyc.getCkycNumber());
|
kyc.markVerified(kyc.getCkycNumber());
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
|
|
||||||
|
// A completed re-KYC is what revives the customer's dormant accounts.
|
||||||
|
//
|
||||||
|
// RBI's Master Direction allows a dormant account to be operated again only once the
|
||||||
|
// customer's identity has been established afresh, and this command is that establishment.
|
||||||
|
// Withdrawals are refused while an account is dormant and no transaction lifts it, so
|
||||||
|
// without this the account would stay frozen with no way out.
|
||||||
|
//
|
||||||
|
// Done here rather than in the nightly expiry sweep on purpose: that sweep visits every
|
||||||
|
// customer whose KYC is merely current, so reviving there would reopen a dormant account on
|
||||||
|
// the next run without anyone having verified anything.
|
||||||
|
final int revived = reviveDormantAccounts(clientId);
|
||||||
|
|
||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("verifiedOn", kyc.getVerifiedOn());
|
changes.put("verifiedOn", kyc.getVerifiedOn());
|
||||||
changes.put("expiryDate", kyc.getExpiryDate());
|
changes.put("expiryDate", kyc.getExpiryDate());
|
||||||
changes.put("kycStatus", kyc.getKycStatusValue());
|
changes.put("kycStatus", kyc.getKycStatusValue());
|
||||||
changes.put("kycStatusCode", "kyc.status.verified");
|
changes.put("kycStatusCode", "kyc.status.verified");
|
||||||
|
if (revived > 0) {
|
||||||
|
changes.put("dormantAccountsRevived", revived);
|
||||||
|
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
|
||||||
|
}
|
||||||
return buildResult(command, client, clientId, kycId, changes);
|
return buildResult(command, client, clientId, kycId, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1084,39 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the dormancy flag from this client's savings accounts, on the strength of a completed
|
||||||
|
* re-KYC.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* RBI's Master Direction allows a dormant account to be operated again only once the customer's
|
||||||
|
* identity has been established afresh, so this is the one thing that lifts it — no transaction
|
||||||
|
* does, and a withdrawal is refused while the flag stands. Called from every path that
|
||||||
|
* completes a verification, not only the explicit complete-rekyc command: an officer who
|
||||||
|
* re-verifies PAN and Aadhaar at the counter has done the same verification and must not have
|
||||||
|
* to know about a second button.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Deliberately not called from the nightly expiry sweep, which visits every customer whose KYC
|
||||||
|
* is merely current — reviving there would reopen a dormant account on the next run without
|
||||||
|
* anyone having verified anything.
|
||||||
|
*
|
||||||
|
* @return how many accounts were revived
|
||||||
|
*/
|
||||||
|
private int reviveDormantAccounts(final Long clientId) {
|
||||||
|
int revived = 0;
|
||||||
|
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
|
||||||
|
if (account.reviveOnReKyc()) {
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
revived++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (revived > 0) {
|
||||||
|
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
|
||||||
|
}
|
||||||
|
return revived;
|
||||||
|
}
|
||||||
|
|
||||||
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
|
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
|
||||||
final Long clientId, final Long kycId, final Map<String, Object> changes) {
|
final Long clientId, final Long kycId, final Map<String, Object> changes) {
|
||||||
return new CommandProcessingResultBuilder()
|
return new CommandProcessingResultBuilder()
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a customer with a lapsed periodic re-KYC may still do with their savings account.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One service because the answer has to be the same at every counter the money can leave by — the
|
||||||
|
* clerk's withdrawal request, the cashier's deposit, an account-to-account transfer, a transfer to
|
||||||
|
* or from a GL. Those are separate resources that each validate their own input, and a rule copied
|
||||||
|
* into four of them is a rule that will disagree with itself the first time one is edited.
|
||||||
|
*
|
||||||
|
* <h3>The ladder</h3>
|
||||||
|
*
|
||||||
|
* <table>
|
||||||
|
* <tr><th>Days overdue</th><th>Money out (withdrawal, transfer out)</th><th>Money in (deposit, transfer in)</th></tr>
|
||||||
|
* <tr><td>0 – 89 (grace)</td><td>allowed</td><td>allowed</td></tr>
|
||||||
|
* <tr><td>90 – 179</td><td>stopped</td><td>allowed</td></tr>
|
||||||
|
* <tr><td>180 and past</td><td>stopped</td><td>stopped</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Nothing at all is restricted during the grace window, and that is the point of it: RBI's 2021
|
||||||
|
* amendment is explicit that an account is not to be restricted merely because periodic KYC is
|
||||||
|
* pending, and that any restriction follows notice and a fair opportunity to comply. The customer
|
||||||
|
* is being chased, not shut out.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The line is drawn by direction, not by the counter the money crosses. Anything taking money out
|
||||||
|
* is stopped once debits stop — a transfer out is a withdrawal by another name — while anything
|
||||||
|
* paying money in keeps working, whether it is cash at the counter or a transfer from another
|
||||||
|
* account. A salary, a benefit, a relative sending funds: those must land, and none of them can
|
||||||
|
* move the balance anywhere while the debit side is shut.
|
||||||
|
*
|
||||||
|
* <h3>Why the days are counted rather than read off the account</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The nightly job stamps the restriction on the savings account. Between a customer crossing a
|
||||||
|
* threshold and that job next running the account still reads as unrestricted, and a request
|
||||||
|
* raised in that window is accepted — the clerk is told it went through and the customer waits for
|
||||||
|
* money that was never going to be paid. Counting here closes that window, and matches the job so
|
||||||
|
* the two can never disagree.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KycRestrictionGuard {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** What the caller is trying to do, which decides how far down the ladder it survives. */
|
||||||
|
public enum Operation {
|
||||||
|
|
||||||
|
/** Money out over the counter. Stopped once debits stop. */
|
||||||
|
DEBIT("Withdrawal"),
|
||||||
|
/** Money in, however it arrives. Allowed until the account is inoperative. */
|
||||||
|
CREDIT("Deposit"),
|
||||||
|
/** Money out to another account or a GL — a withdrawal by another name, so stopped with them. */
|
||||||
|
TRANSFER("Transfer");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Operation(final String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses the operation where the customer's re-KYC has lapsed far enough to stop it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Silent for a null account, a client with no KYC record, and one whose KYC has no expiry date
|
||||||
|
* — none of those is a lapsed re-KYC, and refusing on absent data would block a counter over a
|
||||||
|
* record that was never set up.
|
||||||
|
*
|
||||||
|
* @throws PlatformDataIntegrityException naming the stage and what would lift it
|
||||||
|
*/
|
||||||
|
public void check(final Long savingsAccountId, final Operation operation) {
|
||||||
|
checkDormancy(savingsAccountId, operation);
|
||||||
|
checkKycExpiry(savingsAccountId, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
|
||||||
|
final Long daysOverdue = daysOverdue(savingsAccountId);
|
||||||
|
if (daysOverdue == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
|
||||||
|
if (stage == null || stage.isGrace()) {
|
||||||
|
// Inside the grace window nothing is restricted; the customer is only being chased.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Past grace, a credit is the one thing still let through, until the account is inoperative.
|
||||||
|
if (operation == Operation.CREDIT && !stage.isInoperative()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.client.kyc.lapsed." + operation.name().toLowerCase(),
|
||||||
|
operation.getLabel() + " is not allowed. The customer's periodic re-KYC lapsed " + daysOverdue
|
||||||
|
+ " days ago. Complete the re-KYC to lift this.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses money leaving, or moving, on an account nobody has operated for the product's
|
||||||
|
* dormancy period.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Checked alongside the re-KYC ladder because the two restrict for the same reason and are
|
||||||
|
* lifted by the same act: an account untouched for two years is the one an impersonator
|
||||||
|
* reaches for, and RBI allows it to be operated again only once the customer's identity has
|
||||||
|
* been established afresh. Deposits are left alone — money coming in cannot be a fraud against
|
||||||
|
* the customer — but a transfer moves the balance somewhere a debit block does not follow, so
|
||||||
|
* it is refused in either direction.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The days are counted from the same clock the ladder measures, so an account that crossed the
|
||||||
|
* threshold before the nightly job stamped it is caught all the same.
|
||||||
|
*/
|
||||||
|
private void checkDormancy(final Long savingsAccountId, final Operation operation) {
|
||||||
|
if (savingsAccountId == null || operation == Operation.CREDIT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sp.days_to_dormancy,
|
||||||
|
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
|
||||||
|
- GREATEST(sa.activatedon_date,
|
||||||
|
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
|
||||||
|
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
|
||||||
|
sa.activatedon_date)) AS days_idle
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
|
||||||
|
""", savingsAccountId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Number threshold = (Number) rows.get(0).get("days_to_dormancy");
|
||||||
|
final Number daysIdle = (Number) rows.get(0).get("days_idle");
|
||||||
|
if (threshold == null || daysIdle == null || daysIdle.longValue() < threshold.longValue()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final long years = threshold.longValue() / 365;
|
||||||
|
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
|
||||||
|
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
|
||||||
|
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Measured against the business date, not the wall clock, so a branch working a backdated day
|
||||||
|
* sees the restrictions that applied on that day.
|
||||||
|
*/
|
||||||
|
public Long daysOverdue(final Long savingsAccountId) {
|
||||||
|
if (savingsAccountId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_client_kyc k ON k.client_id = sa.client_id
|
||||||
|
WHERE sa.id = ? AND k.expiry_date IS NOT NULL
|
||||||
|
ORDER BY k.expiry_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", savingsAccountId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
|
||||||
|
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -66,6 +66,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
|||||||
return new KycRiskRuleData(
|
return new KycRiskRuleData(
|
||||||
rule.getId(),
|
rule.getId(),
|
||||||
rule.getRuleName(),
|
rule.getRuleName(),
|
||||||
|
rule.isKycRule() ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML,
|
||||||
rule.getLegalFormId(),
|
rule.getLegalFormId(),
|
||||||
occCatId,
|
occCatId,
|
||||||
occCatName,
|
occCatName,
|
||||||
|
|||||||
+45
-1
@@ -59,6 +59,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
// inserting into the middle of the catalog would re-prioritise existing rules.
|
// inserting into the middle of the catalog would re-prioritise existing rules.
|
||||||
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
|
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
|
||||||
|
|
||||||
|
final String ruleKind = resolveRuleKind(request);
|
||||||
|
|
||||||
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
|
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
|
||||||
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
|
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
|
||||||
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
|
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
|
||||||
@@ -67,7 +69,7 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
|
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
|
||||||
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
|
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
|
||||||
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
|
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
|
||||||
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"));
|
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"), ruleKind);
|
||||||
|
|
||||||
if (request.getOccupationCategoryCvId() != null) {
|
if (request.getOccupationCategoryCvId() != null) {
|
||||||
final CodeValue occupationCategory = this.codeValueRepository
|
final CodeValue occupationCategory = this.codeValueRepository
|
||||||
@@ -88,6 +90,44 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
return this.readPlatformService.retrieveRule(saved.getId());
|
return this.readPlatformService.retrieveRule(saved.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a newly authored rule takes part in onboarding risk categorisation.
|
||||||
|
*
|
||||||
|
* An explicit {@code ruleKind} from the caller wins. Otherwise the kind is inferred once, at
|
||||||
|
* authoring time, from what the request actually describes: a rule carrying live-transaction
|
||||||
|
* settings is a monitoring scenario, and so is a criteria-less rule - it names no occupation,
|
||||||
|
* no legal form and no income or turnover band, so as a KYC rule it would match every customer
|
||||||
|
* who ever walks in. That is precisely how "Additional RFIs Rule - 91" came to categorise a
|
||||||
|
* salaried employee as Medium Risk and flag them for AML.
|
||||||
|
*
|
||||||
|
* Inferring here rather than during evaluation is the point: the answer is written to the row
|
||||||
|
* and stays stable, so a later migration touching the criteria columns cannot silently change
|
||||||
|
* how an existing rule is treated.
|
||||||
|
*/
|
||||||
|
private String resolveRuleKind(final KycRiskRuleRequest request) {
|
||||||
|
if (StringUtils.isNotBlank(request.getRuleKind())) {
|
||||||
|
return ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim()) ? ClientRiskRule.KIND_AML
|
||||||
|
: ClientRiskRule.KIND_KYC;
|
||||||
|
}
|
||||||
|
if (request.hasTransactionRule()) {
|
||||||
|
return ClientRiskRule.KIND_AML;
|
||||||
|
}
|
||||||
|
return hasKycCriteria(request) ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bound of zero is "not configured", the same reading the risk calculator applies. */
|
||||||
|
private boolean hasKycCriteria(final KycRiskRuleRequest request) {
|
||||||
|
return request.getLegalFormId() != null || request.getOccupationCategoryCvId() != null
|
||||||
|
|| request.getOccupationCvId() != null || isConfigured(request.getMinMonthlyIncome())
|
||||||
|
|| isConfigured(request.getMaxMonthlyIncome()) || isConfigured(request.getMinAnnualIncome())
|
||||||
|
|| isConfigured(request.getMaxAnnualIncome()) || isConfigured(request.getMinAnnualTurnover())
|
||||||
|
|| isConfigured(request.getMaxAnnualTurnover());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConfigured(final BigDecimal amount) {
|
||||||
|
return amount != null && amount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
|
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
|
||||||
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
|
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
|
||||||
// so a rule described purely as a band is still evaluable against live transactions.
|
// so a rule described purely as a band is still evaluable against live transactions.
|
||||||
@@ -114,6 +154,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
|
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
|
||||||
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
|
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
|
||||||
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
|
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
|
||||||
|
if (StringUtils.isNotBlank(request.getRuleKind()) && !ClientRiskRule.KIND_KYC.equalsIgnoreCase(request.getRuleKind().trim())
|
||||||
|
&& !ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim())) {
|
||||||
|
validator.reset().parameter("ruleKind").value(request.getRuleKind()).failWithCode("must.be.kyc.or.aml");
|
||||||
|
}
|
||||||
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
|
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
|
||||||
.integerGreaterThanZero();
|
.integerGreaterThanZero();
|
||||||
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
|
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.denominationmaster.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one definition of which {@code m_savings_txn_denomination} rows are real counted cash.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Written here once because it is read from four places -- the branch day register's close preview,
|
||||||
|
* the cashier position report, the cash reconciliation screen and the withdrawal stock check -- and
|
||||||
|
* those places disagreeing is what makes a drawer hold one figure on one screen and another on the
|
||||||
|
* next. A change to the rule belongs in this file and nowhere else.
|
||||||
|
*
|
||||||
|
* <h3>What the rule has to separate</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The {@code txn_id} column is a bigint shared by every register that counts notes, and each writes
|
||||||
|
* a different kind of id into it:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>a savings, current or deposit transaction writes its own transaction id -- always small;
|
||||||
|
* <li>a cashier deposit request writes its request id plus a fixed offset -- also small;
|
||||||
|
* <li>a Demand Draft issue writes its own request id -- small;
|
||||||
|
* <li>a manual GL entry has no numeric id at all, so its hex transaction reference is decoded into
|
||||||
|
* this column (see {@code CashierGlDenominationApiResource}, matching the encoding
|
||||||
|
* {@code CashReportApiResource}'s voucher lookup decodes back out of hex) -- almost always very
|
||||||
|
* large, but a hex string beginning with low digits can land anywhere;
|
||||||
|
* <li>and older code once wrote {@code System.currentTimeMillis()} here, leaving rows belonging to
|
||||||
|
* no transaction at all. Those are phantom cash: a cashier's position is cumulative, so they are
|
||||||
|
* carried forward for ever, and one database held ₹7.29 lakh of them.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Why this is not a magnitude test</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A magnitude test cannot tell the last two apart, and a wider version of this rule -- "reject
|
||||||
|
* anything at or above 10^12" -- rejected every manual GL entry's cash along with the phantom rows.
|
||||||
|
* A narrower band still misses: one live row carries a decoded GL reference of 6,547,106,798,661,
|
||||||
|
* which sits squarely inside the 13-digit band a millisecond epoch occupies.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What actually separates them is that a millisecond epoch stamped at write time decodes back to
|
||||||
|
* approximately the day the row itself belongs to, while a hex reference that happens to land in the
|
||||||
|
* same band decodes to an arbitrary date -- the row above reads as June 2177 against a June 2026
|
||||||
|
* transaction date. So the test reads the id as a date and asks whether it lands on the row's own
|
||||||
|
* day. That is self-maintaining: it needs no constant moved forward as real timestamps grow.
|
||||||
|
*/
|
||||||
|
public final class CountedCashLedger {
|
||||||
|
|
||||||
|
private CountedCashLedger() {}
|
||||||
|
|
||||||
|
/** Widest gap still read as "this id is the moment the row was written", in days. */
|
||||||
|
private static final int SAME_DAY_TOLERANCE_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL predicate keeping only rows whose {@code txn_id} is a real reference, as an expression over
|
||||||
|
* the alias {@code std} ({@code m_savings_txn_denomination}).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The date decoding is confined to the 13-digit band, where {@code to_timestamp} is always inside
|
||||||
|
* PostgreSQL's own timestamp range; ids outside that band are kept without decoding, so no value
|
||||||
|
* this column can hold can overflow the comparison.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Padded with a space at each end so it reads the same spliced into a text block -- which strips
|
||||||
|
* the trailing space off the {@code AND } it follows -- as concatenated onto a plain string.
|
||||||
|
*/
|
||||||
|
public static final String REAL_TXN_ID = " (std.txn_id IS NULL"
|
||||||
|
+ " OR std.txn_id < 1000000000000 OR std.txn_id >= 10000000000000"
|
||||||
|
+ " OR COALESCE(std.txn_date, CAST(std.created_at AS DATE)) IS NULL"
|
||||||
|
+ " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN"
|
||||||
|
+ " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS
|
||||||
|
+ " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") ";
|
||||||
|
}
|
||||||
+22
-3
@@ -126,8 +126,17 @@ public class DenominationBreakdownWriter {
|
|||||||
"Denomination breakdown validation errors exist.", errors);
|
"Denomination breakdown validation errors exist.", errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Grand total must match the transaction amount
|
// 5. Grand total must match the transaction amount, to within a rupee.
|
||||||
if (expectedAmount != null && computedTotal.compareTo(expectedAmount) != 0) {
|
//
|
||||||
|
// Exact equality made a whole class of legitimate transaction impossible to complete: an
|
||||||
|
// interest-bearing withdrawal or closure settles on a paisa figure (₹12,998.56), Indian cash
|
||||||
|
// has no paisa coins, so NO set of notes can ever sum to it and every breakdown the cashier
|
||||||
|
// entered was rejected. Sub-rupee is therefore accepted as the rounding it is -- the same
|
||||||
|
// tolerance CashReportApiResource's own breakdown check applies on the reporting side, so
|
||||||
|
// the two agree instead of one accepting what the other rejects. A genuinely mis-keyed
|
||||||
|
// breakdown (₹1,60,000 of notes against a ₹918 deposit) is off by far more than a rupee and
|
||||||
|
// is still refused.
|
||||||
|
if (expectedAmount != null && computedTotal.subtract(expectedAmount).abs().compareTo(BigDecimal.ONE) >= 0) {
|
||||||
throw new PlatformDataIntegrityException(
|
throw new PlatformDataIntegrityException(
|
||||||
"error.msg.denomination.total.mismatch",
|
"error.msg.denomination.total.mismatch",
|
||||||
label + " denomination breakdown total (" + computedTotal
|
label + " denomination breakdown total (" + computedTotal
|
||||||
@@ -277,6 +286,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -300,6 +310,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -309,7 +320,13 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
|
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
|
||||||
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
|
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
|
||||||
+ " GROUP BY ced.denomination_id"
|
+ " GROUP BY ced.denomination_id"
|
||||||
+ " ) raw GROUP BY denomination_id HAVING SUM(net_count) > 0"
|
// No "HAVING SUM(net_count) > 0" here: dropping the denominations that net negative
|
||||||
|
// reports MORE cash than the drawer holds, because the shortfall they represent is
|
||||||
|
// silently discarded instead of subtracted. Net -20x₹500 with +30x₹100 is -₹7,000,
|
||||||
|
// not +₹3,000 -- and this figure is what validateCashierHasTotal lets a withdrawal
|
||||||
|
// through on, so the optimistic version hands out money that is not there. It also
|
||||||
|
// disagreed with getCashierNetPosition below, which has never filtered them.
|
||||||
|
+ " ) raw GROUP BY denomination_id"
|
||||||
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
|
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
|
||||||
|
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
|
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
|
||||||
@@ -390,6 +407,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||||
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
@@ -413,6 +431,7 @@ public class DenominationBreakdownWriter {
|
|||||||
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
|
||||||
+ " FROM m_savings_txn_denomination std"
|
+ " FROM m_savings_txn_denomination std"
|
||||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||||
|
+ " AND " + CountedCashLedger.REAL_TXN_ID
|
||||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||||
+ " GROUP BY std.denomination_id"
|
+ " GROUP BY std.denomination_id"
|
||||||
|
|||||||
+28
-17
@@ -45,6 +45,8 @@ 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.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -94,12 +96,11 @@ public class DividendClaimRequestApiResource {
|
|||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
/** The dividend GL accounts, as configured on Accounting > Financial Activity Mappings. */
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
|
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
|
||||||
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
|
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
|
||||||
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
|
|
||||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
|
||||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
|
|
||||||
@@ -492,23 +493,31 @@ public class DividendClaimRequestApiResource {
|
|||||||
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
|
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
|
||||||
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
|
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The DD Payable liability a dividend draft is raised against.
|
||||||
|
*
|
||||||
|
* m_sponsor_dd_gl_mapping holds a single global row — it was keyed by payment type until 0349 dropped that column,
|
||||||
|
* and the read side reports it as "Global" — so there is one mapping for every sponsor bank, and the first row is
|
||||||
|
* the only row. Kept as its own method so this reads as a deliberate choice rather than an arbitrary LIMIT 1, and
|
||||||
|
* so it fails loudly if the mapping has not been configured at all.
|
||||||
|
*/
|
||||||
|
private Long ddPayableAccount() {
|
||||||
|
final Long glAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (glAccountId == null) {
|
||||||
|
throw new IllegalStateException("No Sponsor DD Payable GL account is configured. Set it under "
|
||||||
|
+ "Organization > Sponsor DD GST / GL Settings before approving a dividend claim.");
|
||||||
|
}
|
||||||
|
return glAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
|
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
|
||||||
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
|
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
|
||||||
|
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
final Long dividendPaidAccountId = dividendGlAccounts.dividendPaidAccountOrNull();
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
final Long ddPayableAccountId = ddPayableAccount();
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long dividendPaidAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_DIVIDEND_PAID)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long ddPayableAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
|
|
||||||
.findFirst().orElse(null);
|
|
||||||
if (payableDividendsAccountId == null || ddPayableAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
|
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
|
||||||
final String transactionId = "DIVDD" + claimId;
|
final String transactionId = "DIVDD" + claimId;
|
||||||
@@ -587,6 +596,8 @@ public class DividendClaimRequestApiResource {
|
|||||||
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
|
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
|
||||||
final AppUser currentUser) {
|
final AppUser currentUser) {
|
||||||
|
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
|
||||||
final String insertSql = """
|
final String insertSql = """
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||||
|
|||||||
+8
-6
@@ -98,6 +98,7 @@ public class BranchProfileApiResource {
|
|||||||
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
|
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
|
||||||
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
|
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
|
||||||
final String address = this.fromJsonHelper.extractStringNamed("address", root);
|
final String address = this.fromJsonHelper.extractStringNamed("address", root);
|
||||||
|
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
|
||||||
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
|
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
|
||||||
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
|
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
|
||||||
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
|
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
|
||||||
@@ -124,22 +125,22 @@ public class BranchProfileApiResource {
|
|||||||
if (existing != null && existing > 0) {
|
if (existing != null && existing > 0) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_office_profile
|
UPDATE m_office_profile
|
||||||
SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?,
|
SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?,
|
||||||
working_hours_start = ?, working_hours_end = ?, working_days = ?,
|
working_hours_start = ?, working_hours_end = ?, working_days = ?,
|
||||||
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
|
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
|
||||||
updated_by_username = ?, updated_at = ?
|
updated_by_username = ?, updated_at = ?
|
||||||
WHERE office_id = ?
|
WHERE office_id = ?
|
||||||
""", branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
|
""", branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
|
||||||
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
||||||
Timestamp.from(Instant.now()), officeId);
|
Timestamp.from(Instant.now()), officeId);
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_office_profile
|
INSERT INTO m_office_profile
|
||||||
(office_id, branch_code, branch_type, address, contact_phone, contact_email,
|
(office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email,
|
||||||
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
|
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
|
||||||
latitude, longitude, updated_by_username, updated_at)
|
latitude, longitude, updated_by_username, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""", officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
|
""", officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart,
|
||||||
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
||||||
Timestamp.from(Instant.now()));
|
Timestamp.from(Instant.now()));
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,7 @@ public class BranchProfileApiResource {
|
|||||||
private String baseSql() {
|
private String baseSql() {
|
||||||
return """
|
return """
|
||||||
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date,
|
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date,
|
||||||
p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email,
|
p.branch_code, p.branch_type, p.address, p.pincode, p.contact_phone, p.contact_email,
|
||||||
p.working_hours_start, p.working_hours_end, p.working_days,
|
p.working_hours_start, p.working_hours_end, p.working_days,
|
||||||
p.ifsc_code, p.micr_code, p.latitude, p.longitude,
|
p.ifsc_code, p.micr_code, p.latitude, p.longitude,
|
||||||
p.updated_by_username, p.updated_at
|
p.updated_by_username, p.updated_at
|
||||||
@@ -169,6 +170,7 @@ public class BranchProfileApiResource {
|
|||||||
out.put("branchCode", row.get("branch_code"));
|
out.put("branchCode", row.get("branch_code"));
|
||||||
out.put("branchType", row.get("branch_type"));
|
out.put("branchType", row.get("branch_type"));
|
||||||
out.put("address", row.get("address"));
|
out.put("address", row.get("address"));
|
||||||
|
out.put("pincode", row.get("pincode"));
|
||||||
out.put("contactPhone", row.get("contact_phone"));
|
out.put("contactPhone", row.get("contact_phone"));
|
||||||
out.put("contactEmail", row.get("contact_email"));
|
out.put("contactEmail", row.get("contact_email"));
|
||||||
out.put("workingHoursStart", row.get("working_hours_start"));
|
out.put("workingHoursStart", row.get("working_hours_start"));
|
||||||
|
|||||||
+80
-10
@@ -48,8 +48,19 @@ public class SavingsDailyInterestOrchestrator {
|
|||||||
private final SavingsDailyInterestService accrualService;
|
private final SavingsDailyInterestService accrualService;
|
||||||
private final SavingsPooledInterestPostingService postingService;
|
private final SavingsPooledInterestPostingService postingService;
|
||||||
|
|
||||||
/** Accrue for the day, then post for any product whose cycle ends today. */
|
/**
|
||||||
|
* Accrue for the day, then post for any product whose posting date has arrived.
|
||||||
|
*
|
||||||
|
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
|
||||||
|
* posting then reads and updates those same rows in a transaction of its own
|
||||||
|
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
|
||||||
|
* another's payment). Holding an outer transaction across both would mean this thread still owns
|
||||||
|
* the row locks the accrual took while the posting's separate transaction waits for them — each
|
||||||
|
* waiting on the other, and the job hanging silently with nothing in the log. So each step
|
||||||
|
* commits on its own before the next begins.
|
||||||
|
*/
|
||||||
public void runForDate(final LocalDate businessDate) {
|
public void runForDate(final LocalDate businessDate) {
|
||||||
|
clearStaleRunningFlag();
|
||||||
accrualService.accrueForDate(businessDate);
|
accrualService.accrueForDate(businessDate);
|
||||||
enrolNewProducts();
|
enrolNewProducts();
|
||||||
|
|
||||||
@@ -61,33 +72,92 @@ public class SavingsDailyInterestOrchestrator {
|
|||||||
ORDER BY s.savings_product_id
|
ORDER BY s.savings_product_id
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// Products that could not be paid, collected so the run can fail loudly at the end.
|
||||||
|
//
|
||||||
|
// Carrying on past one product is right — a mapping missing on one product must not stop
|
||||||
|
// every member of every other from being paid. Reporting the job as successful afterwards is
|
||||||
|
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
|
||||||
|
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
|
||||||
|
// nothing reads. On a night when every product fails, that is a whole day of interest
|
||||||
|
// silently unpaid.
|
||||||
|
final List<String> failures = new java.util.ArrayList<>();
|
||||||
|
|
||||||
for (final Map<String, Object> schedule : schedules) {
|
for (final Map<String, Object> schedule : schedules) {
|
||||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||||
final Long dueCount = jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COUNT(*) FROM m_savings_daily_interest
|
// Whether anything is due is decided inside postForProduct, not here.
|
||||||
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
|
//
|
||||||
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
|
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
|
||||||
""", Long.class, productId, businessDate);
|
// deadlocked the run: accrueForDate above is @Transactional and has just written every
|
||||||
if (dueCount == null || dueCount == 0) {
|
// ledger row, so its transaction still holds them; the COUNT then read those rows on
|
||||||
|
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
|
||||||
|
// transaction of its own — sat waiting for a lock this thread was holding and would only
|
||||||
|
// release after postForProduct returned. Each waited on the other and the job hung
|
||||||
|
// silently, with nothing in the log and the flag left saying it was still running.
|
||||||
|
//
|
||||||
|
// postForProduct already selects what is due and returns zero when there is nothing, so
|
||||||
|
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
|
||||||
|
// than by the run date, which is what makes a missed posting date catch up on a later
|
||||||
|
// run instead of being skipped for good.
|
||||||
|
try {
|
||||||
|
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||||
|
if (paid.signum() == 0) {
|
||||||
updateSchedule(productId, businessDate, null);
|
updateSchedule(productId, businessDate, null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
|
||||||
updateSchedule(productId, businessDate, "COMPLETED");
|
updateSchedule(productId, businessDate, "COMPLETED");
|
||||||
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
// The product rolled back on its own; record why and let the others run.
|
// The product rolled back on its own; record why and let the others run.
|
||||||
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
||||||
|
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
|
||||||
|
// the row — which is exactly what a product with no GL mapping does. The failure is
|
||||||
|
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_savings_interest_posting_run
|
UPDATE m_savings_interest_posting_run
|
||||||
SET status = 'FAILED', failure_reason = ?
|
SET status = 'FAILED', failure_reason = ?
|
||||||
WHERE savings_product_id = ? AND posting_date = ?
|
WHERE savings_product_id = ? AND posting_date = ?
|
||||||
""", truncate(e.getMessage()), productId, businessDate);
|
""", truncate(e.getMessage()), productId, businessDate);
|
||||||
updateSchedule(productId, businessDate, "FAILED");
|
updateSchedule(productId, businessDate, "FAILED");
|
||||||
|
failures.add("product " + productId + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fails the job so the scheduler records it as failed and an operator is told. Every product
|
||||||
|
// that could be paid has already been paid and committed by this point — this reports what
|
||||||
|
// happened, it does not undo it.
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
||||||
|
+ failures.size() + " product(s): " + String.join("; ", failures));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases this job's own currently_running flag if an earlier run left it set.
|
||||||
|
*
|
||||||
|
* The platform sets that flag in the scheduler's veto check before a job's body runs, and clears
|
||||||
|
* it in the Quartz listener when the body returns. Two things leave it set with nothing running:
|
||||||
|
* a JVM stopped mid-run, and a run started through this service's own API rather than through
|
||||||
|
* Quartz — the listener is never involved there, so nothing ever clears it.
|
||||||
|
*
|
||||||
|
* Once set, the veto silently drops every later trigger: no run-history row, no error, nothing in
|
||||||
|
* the log. The job reads as though it ran and did nothing, which is exactly how a night of unpaid
|
||||||
|
* interest goes unnoticed. It happened here — a manual API run left the flag behind and the job
|
||||||
|
* was mute from then on.
|
||||||
|
*
|
||||||
|
* Reaching this line is itself the proof that no other run is in flight: Quartz will not start a
|
||||||
|
* second execution of a job it is already running, and this is the first statement of the only
|
||||||
|
* method that runs one. So the flag can only be a leftover, and clearing it means one dead or
|
||||||
|
* out-of-band run cannot mute the job for good.
|
||||||
|
*/
|
||||||
|
private void clearStaleRunningFlag() {
|
||||||
|
final int cleared = jdbcTemplate.update("""
|
||||||
|
UPDATE job SET currently_running = false
|
||||||
|
WHERE name = 'Savings Daily Interest Accrual And Posting' AND currently_running = true
|
||||||
|
""");
|
||||||
|
if (cleared > 0) {
|
||||||
|
log.warn("Savings interest: cleared a stale currently_running flag left by an earlier run");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+205
-5
@@ -23,6 +23,7 @@ import java.math.MathContext;
|
|||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -194,11 +195,19 @@ public class SavingsDailyInterestService {
|
|||||||
// today's figures. Only a row for a day before today can be carried forward — one already
|
// today's figures. Only a row for a day before today can be carried forward — one already
|
||||||
// stamped with today or later means the day has been accrued and re-reading it as "previous"
|
// stamped with today or later means the day has been accrued and re-reading it as "previous"
|
||||||
// would compound the same day's interest onto itself.
|
// would compound the same day's interest onto itself.
|
||||||
|
// Newest first, one row. The table currently carries a unique constraint on
|
||||||
|
// savings_account_id so at most one row can match and the ordering is academic — but the
|
||||||
|
// code below reads previous.get(0) as "yesterday", and the moment per-day history is
|
||||||
|
// restored an unordered query would hand it an arbitrary earlier day as the opening balance
|
||||||
|
// and silently break the chain. Stating the intent costs nothing and does not depend on the
|
||||||
|
// constraint staying where it is.
|
||||||
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||||
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
||||||
cycle_start_date, cycle_end_date, is_posting_day
|
cycle_start_date, cycle_end_date, is_posting_day
|
||||||
FROM m_savings_daily_interest
|
FROM m_savings_daily_interest
|
||||||
WHERE savings_account_id = ? AND balance_date < ?
|
WHERE savings_account_id = ? AND balance_date < ?
|
||||||
|
ORDER BY balance_date DESC
|
||||||
|
LIMIT 1
|
||||||
""", accountId, businessDate);
|
""", accountId, businessDate);
|
||||||
|
|
||||||
final BigDecimal openingBalance;
|
final BigDecimal openingBalance;
|
||||||
@@ -208,8 +217,6 @@ public class SavingsDailyInterestService {
|
|||||||
final LocalDate cycleEnd;
|
final LocalDate cycleEnd;
|
||||||
|
|
||||||
if (previous.isEmpty()) {
|
if (previous.isEmpty()) {
|
||||||
openingBalance = BigDecimal.ZERO;
|
|
||||||
carriedInterest = BigDecimal.ZERO;
|
|
||||||
cycleNumber = 1;
|
cycleNumber = 1;
|
||||||
// The first row belongs to the cycle the day it records falls in, and starts at
|
// The first row belongs to the cycle the day it records falls in, and starts at
|
||||||
// activation only when that day is still inside the account's opening cycle. Anchoring
|
// activation only when that day is still inside the account's opening cycle. Anchoring
|
||||||
@@ -221,6 +228,19 @@ public class SavingsDailyInterestService {
|
|||||||
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
|
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
|
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
|
||||||
|
// The account has earned since its cycle opened, but the ledger only starts today —
|
||||||
|
// either because the account predates this ledger, or because the nightly run has not
|
||||||
|
// reached it before now. Taking the opening balance as zero and the carried interest as
|
||||||
|
// nothing would credit the member for today alone and silently drop every day since
|
||||||
|
// cycleStart, which is exactly what a closure then pays out.
|
||||||
|
//
|
||||||
|
// So the days already gone are replayed here, from the account's own transactions, to
|
||||||
|
// arrive at the balance and the running interest the ledger would be holding had it
|
||||||
|
// been running all along.
|
||||||
|
final Backfill backfill = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||||
|
minBalance, averageDailyBalanceOf(calcType));
|
||||||
|
openingBalance = backfill.closingBalance();
|
||||||
|
carriedInterest = backfill.cumulativeInterest();
|
||||||
} else {
|
} else {
|
||||||
final Map<String, Object> prev = previous.get(0);
|
final Map<String, Object> prev = previous.get(0);
|
||||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
@@ -237,10 +257,31 @@ public class SavingsDailyInterestService {
|
|||||||
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
} else {
|
} else {
|
||||||
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
|
||||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||||
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||||
cycleEnd = prevCycleEnd;
|
cycleEnd = prevCycleEnd;
|
||||||
|
|
||||||
|
// Days between the last row and today are earned days too, and have to be added.
|
||||||
|
//
|
||||||
|
// The ledger keeps one row per account, so the "previous" row is simply the last day
|
||||||
|
// the job ran for this account — not necessarily yesterday. Where nights were missed,
|
||||||
|
// or a business date was moved forward by more than a day, every day in between is a
|
||||||
|
// day the member held a balance and earned on it. Carrying the previous row's total
|
||||||
|
// forward unchanged and adding only today's interest silently drops all of them: an
|
||||||
|
// account activated on the 8th and accrued again on the 30th was credited two days'
|
||||||
|
// interest instead of twenty-three.
|
||||||
|
//
|
||||||
|
// So the gap is replayed from the account's own transactions, the same way a first
|
||||||
|
// row is built, and the result stands in for the running total the ledger would be
|
||||||
|
// holding had it run every night.
|
||||||
|
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||||
|
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
|
||||||
|
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||||
|
minBalance, averageDailyBalanceOf(calcType));
|
||||||
|
carriedInterest = gap.cumulativeInterest();
|
||||||
|
} else {
|
||||||
|
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +290,7 @@ public class SavingsDailyInterestService {
|
|||||||
|
|
||||||
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
|
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
|
||||||
// whichever balance the account actually earns on, which is not always today's closing.
|
// whichever balance the account actually earns on, which is not always today's closing.
|
||||||
final boolean averageDailyBalance = calcType != null
|
final boolean averageDailyBalance = averageDailyBalanceOf(calcType);
|
||||||
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
|
|
||||||
|
|
||||||
final BigDecimal dailyInterest;
|
final BigDecimal dailyInterest;
|
||||||
final BigDecimal cumulative;
|
final BigDecimal cumulative;
|
||||||
@@ -389,6 +429,166 @@ public class SavingsDailyInterestService {
|
|||||||
* It cannot double-count, because a posting only ever falls on one day and the row for that day
|
* It cannot double-count, because a posting only ever falls on one day and the row for that day
|
||||||
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
|
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Brings one account's ledger up to the business date after its balance has moved.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The ledger is written by the nightly run, so between a deposit and the next run the account's
|
||||||
|
* row still states yesterday's balance and yesterday's interest. A closure in that window reads
|
||||||
|
* the stale row and pays out interest on a balance the member no longer has — or, for money paid
|
||||||
|
* in today, on no balance at all. Calling this as soon as the balance moves keeps the row in step
|
||||||
|
* with the account, so what a closure reads is what the account actually holds.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Re-accruing the same day is safe. The row is rewritten in place from the day before it, never
|
||||||
|
* from itself, so running this repeatedly through a busy day converges on the same figures rather
|
||||||
|
* than compounding them. An account with no row yet gets its first one here, backfilled from the
|
||||||
|
* cycle start exactly as {@link #accrueOnActivation} would have written it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Never fails the caller: the transaction that moved the money has already succeeded and must not
|
||||||
|
* be rolled back because the ledger could not be refreshed. A failure is logged and left for the
|
||||||
|
* nightly run.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void refreshAfterBalanceChange(final Long savingsAccountId, final LocalDate businessDate) {
|
||||||
|
try {
|
||||||
|
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ? AND sp.deposit_type_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ?
|
||||||
|
""", savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate);
|
||||||
|
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Savings daily interest: could not refresh the ledger for account {} after a balance change; the "
|
||||||
|
+ "nightly run will pick it up", savingsAccountId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean averageDailyBalanceOf(final Number calcType) {
|
||||||
|
return calcType != null && calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The balance and running interest the ledger would hold on the eve of its first row. */
|
||||||
|
private record Backfill(BigDecimal closingBalance, BigDecimal cumulativeInterest) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays the days between a cycle's start and the first day the ledger records, so an account
|
||||||
|
* whose ledger opens mid-cycle carries in the interest it has already earned.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Without this the first row starts from zero on both counts, and every day from cycleStart up to
|
||||||
|
* the day before is lost — the member is credited for one day of a cycle they have been in for
|
||||||
|
* weeks. It is only reachable on an account's very first row: once the ledger has a row, the chain
|
||||||
|
* carries itself forward.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The replay walks the account's own transactions, so it reconstructs the same daily closing
|
||||||
|
* balances the nightly run would have written, including days the balance changed. Days before the
|
||||||
|
* account's activation cannot earn and are not walked — cycleStart is already clamped to activation
|
||||||
|
* by the caller. Reversed transactions are excluded exactly as {@link #dayMovement} excludes them.
|
||||||
|
*
|
||||||
|
* @return the closing balance and cumulative interest as at {@code upTo.minusDays(1)}
|
||||||
|
*/
|
||||||
|
private Backfill backfillFromCycleStart(final Long accountId, final LocalDate cycleStart, final LocalDate upTo,
|
||||||
|
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
|
||||||
|
|
||||||
|
if (!cycleStart.isBefore(upTo)) {
|
||||||
|
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
|
||||||
|
// cycleStart is carried in as the opening figure rather than replayed day by day: interest is
|
||||||
|
// earned within the cycle, but the money that earns it may have been deposited long before.
|
||||||
|
final BigDecimal openingBalance = balanceAsOf(accountId, cycleStart.minusDays(1));
|
||||||
|
final Map<LocalDate, BigDecimal> movements = movementsBetween(accountId, cycleStart, upTo.minusDays(1));
|
||||||
|
|
||||||
|
BigDecimal balance = openingBalance;
|
||||||
|
BigDecimal cumulative = BigDecimal.ZERO;
|
||||||
|
BigDecimal balanceDaysTotal = BigDecimal.ZERO;
|
||||||
|
int days = 0;
|
||||||
|
|
||||||
|
for (LocalDate day = cycleStart; day.isBefore(upTo); day = day.plusDays(1)) {
|
||||||
|
balance = balance.add(movements.getOrDefault(day, BigDecimal.ZERO));
|
||||||
|
balanceDaysTotal = balanceDaysTotal.add(balance);
|
||||||
|
days++;
|
||||||
|
|
||||||
|
if (!averageDailyBalance) {
|
||||||
|
final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& balance.compareTo(minBalance) < 0;
|
||||||
|
if (!belowMinimum && balance.compareTo(BigDecimal.ZERO) > 0 && rate != null
|
||||||
|
&& rate.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
cumulative = cumulative.add(balance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageDailyBalance) {
|
||||||
|
// The same shape the daily path uses: the cycle's average balance, carried across the days
|
||||||
|
// walked, rather than a sum of per-day figures.
|
||||||
|
final BigDecimal averageBalance = balanceDaysTotal.divide(BigDecimal.valueOf(days), MC);
|
||||||
|
final boolean belowMinimum = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& averageBalance.compareTo(minBalance) < 0;
|
||||||
|
if (!belowMinimum && averageBalance.compareTo(BigDecimal.ZERO) > 0 && rate != null
|
||||||
|
&& rate.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
cumulative = averageBalance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC)
|
||||||
|
.multiply(BigDecimal.valueOf(days), MC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Backfill(balance, cumulative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account's balance at the close of a given day, from its transactions. */
|
||||||
|
private BigDecimal balanceAsOf(final Long accountId, final LocalDate asOf) {
|
||||||
|
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE
|
||||||
|
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||||
|
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||||
|
ELSE 0 END), 0)
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
WHERE t.savings_account_id = ?
|
||||||
|
AND t.transaction_date <= ?
|
||||||
|
AND t.is_reversed = false
|
||||||
|
""", BigDecimal.class, accountId, asOf);
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Net movement per day across a date range, for days that had any. */
|
||||||
|
private Map<LocalDate, BigDecimal> movementsBetween(final Long accountId, final LocalDate from, final LocalDate to) {
|
||||||
|
final Map<LocalDate, BigDecimal> byDay = new HashMap<>();
|
||||||
|
jdbcTemplate.queryForList("""
|
||||||
|
SELECT t.transaction_date AS d,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||||
|
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||||
|
ELSE 0 END) AS net
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
WHERE t.savings_account_id = ?
|
||||||
|
AND t.transaction_date >= ? AND t.transaction_date <= ?
|
||||||
|
AND t.is_reversed = false
|
||||||
|
GROUP BY t.transaction_date
|
||||||
|
""", accountId, from, to)
|
||||||
|
.forEach(row -> byDay.put(((java.sql.Date) row.get("d")).toLocalDate(), (BigDecimal) row.get("net")));
|
||||||
|
return byDay;
|
||||||
|
}
|
||||||
|
|
||||||
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
||||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(CASE
|
SELECT COALESCE(SUM(CASE
|
||||||
|
|||||||
+170
-61
@@ -26,9 +26,9 @@ 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.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,24 +40,41 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||||
* holds the figure that would say so.
|
* holds the figure that would say so.
|
||||||
*
|
*
|
||||||
* This posts the product's total first, into the liability the product names as its Savings Interest
|
* This settles the debt the accrual has already recognised: the product's total moves out of the
|
||||||
* Payable, and only then pays it out to members:
|
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
|
||||||
|
* and only then to the members:
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* Dr Savings Interest Paid / Interest On Savings (expense, from the product)
|
* Dr Savings Interest Payable (liability, from the product)
|
||||||
* Cr Savings Interest Payable (liability, from the product)
|
* Cr Savings Interest Paid on its way out
|
||||||
*
|
*
|
||||||
* Dr Savings Interest Payable (per member)
|
* Dr Savings Interest Paid (per member)
|
||||||
* Cr Savings Control (per member, from the product)
|
* Cr Savings Control (per member, from the product) in the member's hands
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* Every account named above is read from the product's own GL configuration; none is written into
|
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
|
||||||
* this class. A product that configures no payable falls back to the two seeded control accounts,
|
* this is the settlement of what that recognition already booked - raising the expense again at
|
||||||
* which keeps products predating that mapping posting exactly as before.
|
* payout would put the same interest on the P&L twice.
|
||||||
*
|
*
|
||||||
* The whole product settles inside one transaction, and the payable has to read zero before it is
|
* Every account named above is read from the product's own GL configuration; none is written into
|
||||||
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
|
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
||||||
* that never reached commit leaves a PENDING row naming the product and date for the next run.
|
* where that is unset, from the product's reserved GL mapping of the same name, and failing both
|
||||||
|
* from its Savings Control - interest owed is a liability either way.
|
||||||
|
*
|
||||||
|
* The first step is dropped where it would say nothing, and the payout then comes straight out of
|
||||||
|
* the payable:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Savings Interest Payable (per member)
|
||||||
|
* Cr Savings Control (per member)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* That happens when the product names no paid account, so both legs of the first step resolve to the
|
||||||
|
* payable and it would debit and credit one account for the same amount.
|
||||||
|
*
|
||||||
|
* The whole product settles inside one transaction. A failure rolls the product back entirely -
|
||||||
|
* nobody is half paid - and a run that never reached commit leaves a PENDING row naming the product
|
||||||
|
* and date for the next run.
|
||||||
*
|
*
|
||||||
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
||||||
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
||||||
@@ -75,34 +92,45 @@ public class SavingsPooledInterestPostingService {
|
|||||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserved names in the product's own GL mappings, which is where the two legs of the interest
|
* Reserved names in the product's own GL mappings, which is where the middle two legs of the
|
||||||
* bill are configured. The names are the contract between this service and the product screen —
|
* interest bill are configured. The names are the contract between this service and the product
|
||||||
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account.
|
* screen — the GL codes behind them are the bank's choice, not ours, so nothing here names an
|
||||||
|
* account. A product that leaves one unset falls back to another mapping the same product
|
||||||
|
* carries; no GL code is ever written into this file.
|
||||||
*/
|
*/
|
||||||
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
|
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
|
||||||
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
|
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
|
||||||
|
|
||||||
/** Fallback control accounts, used only where the product leaves the mappings unset. */
|
|
||||||
private static final String GL_POSTING_CONTROL = "200304";
|
|
||||||
private static final String GL_POSTED_CONTROL = "200305";
|
|
||||||
|
|
||||||
/** m_savings_account_transaction.transaction_type_enum */
|
/** m_savings_account_transaction.transaction_type_enum */
|
||||||
private static final int INTEREST_POSTING = 3;
|
private static final int INTEREST_POSTING = 3;
|
||||||
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||||
private static final int ENTITY_TYPE_SAVINGS = 2;
|
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
|
private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
|
||||||
|
/**
|
||||||
|
* {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product's
|
||||||
|
* Accounting tab, which is where a bank picks the account interest is owed from.
|
||||||
|
*/
|
||||||
|
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one product's interest for one posting date.
|
* Posts one product's interest for one posting date.
|
||||||
*
|
*
|
||||||
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
|
|
||||||
* product's completed run down with it.
|
|
||||||
*
|
|
||||||
* @return the amount distributed, or zero if there was nothing to do
|
* @return the amount distributed, or zero if there was nothing to do
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* REQUIRED, not REQUIRES_NEW.
|
||||||
|
*
|
||||||
|
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
|
||||||
|
* the orchestrator calls this outside any transaction of its own, so each product opens and
|
||||||
|
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
|
||||||
|
* running and waits for locks that transaction may hold. The accrual step immediately before
|
||||||
|
* writes every account's ledger row, and this method reads and updates those same rows: with
|
||||||
|
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
|
||||||
|
* and the job hung with nothing in the log and its currently_running flag left set.
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional
|
||||||
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
||||||
final Long completed = jdbcTemplate.queryForObject("""
|
final Long completed = jdbcTemplate.queryForObject("""
|
||||||
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
||||||
@@ -113,8 +141,18 @@ public class SavingsPooledInterestPostingService {
|
|||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The accounts due today, with what they accrued. Anything already carrying a posting
|
// Every account whose posting date has arrived or passed and has not yet been paid.
|
||||||
// transaction is skipped, so a resumed run cannot pay somebody a second time.
|
//
|
||||||
|
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
|
||||||
|
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
|
||||||
|
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
|
||||||
|
// run on was never paid at all — the cycle simply rolled over with the interest carried into
|
||||||
|
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
|
||||||
|
// to behave when a night is missed.
|
||||||
|
//
|
||||||
|
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
|
||||||
|
// somebody a second time.
|
||||||
|
//
|
||||||
// A savings account has no office of its own: it belongs to a client, or to a group when
|
// A savings account has no office of its own: it belongs to a client, or to a group when
|
||||||
// there is no client, and the office is theirs. The journal entries have to be raised
|
// there is no client, and the office is theirs. The journal entries have to be raised
|
||||||
// against that office or they land in the wrong branch's books.
|
// against that office or they land in the wrong branch's books.
|
||||||
@@ -127,7 +165,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
|
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
||||||
AND d.savings_transaction_id IS NULL
|
AND d.savings_transaction_id IS NULL
|
||||||
AND d.cycle_cumulative_interest > 0
|
AND d.cycle_cumulative_interest > 0
|
||||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||||
@@ -164,36 +202,55 @@ public class SavingsPooledInterestPostingService {
|
|||||||
largest.get("savings_account_id"));
|
largest.get("savings_account_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The expense the bill is recognised against, and the liability it sits in until it reaches
|
// The three accounts the bill passes through on its way to the members. Every one is read
|
||||||
// the members. Both come from the product: "Savings Interest Paid" if the product names one,
|
// from the product's own configuration, so a bank on its own chart of accounts is posted to
|
||||||
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the
|
// the accounts it chose. No GL code is named in this class.
|
||||||
// liability, falling back to the control accounts only where a product configures neither.
|
//
|
||||||
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID),
|
// The product's Accounting tab is the authority, and it is consulted first for all three:
|
||||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS));
|
// Interest On Savings for the expense, and the standard Interest Payable row for the
|
||||||
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE);
|
// payable. Those are the rows a bank actually fills in on the product screen.
|
||||||
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL));
|
//
|
||||||
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL);
|
// The reserved names in the product's other GL mappings are consulted only after that, as a
|
||||||
|
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
|
||||||
|
// which has no standard row of its own.
|
||||||
|
//
|
||||||
|
// Where a product sets none of them the bill still has somewhere to go: the payable falls
|
||||||
|
// back to the product's Savings Control (interest owed is a liability either way), and the
|
||||||
|
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
|
||||||
|
// posting a product without these mappings has always had.
|
||||||
|
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||||
|
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||||
|
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
||||||
|
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
||||||
|
|
||||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||||
final String currency = (String) due.get(0).get("currency_code");
|
final String currency = (String) due.get(0).get("currency_code");
|
||||||
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||||
|
|
||||||
// Stage 1: the product's whole interest bill, recognised and parked in the payable.
|
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
||||||
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
// account the payout is made from, so what is still merely owed and what is on its way to
|
||||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
// the members are two separate balances rather than one.
|
||||||
|
|
||||||
// Stage 2: the pool moves to the account it is paid out of.
|
|
||||||
//
|
//
|
||||||
// Where the product names a single payable, both control legs resolve to it and this stage
|
// The interest expense itself is not raised here. It is recognised as the interest accrues,
|
||||||
// would debit and credit the same account for the same amount — a pair of entries that say
|
// day by day, and posting is the settlement of that recognised debt — raising the expense
|
||||||
// nothing and clutter the ledger. Only a product still on the two distinct control accounts
|
// again at payout would count the same interest twice on the P&L.
|
||||||
// needs the transfer written.
|
//
|
||||||
if (!postingControlGl.equals(postedControlGl)) {
|
// Skipped where the product names no separate paid account: both legs then resolve to the
|
||||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
// payable and this would debit and credit one account for the same amount, saying nothing.
|
||||||
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
final boolean payableIsPaid = payableGl.equals(paidGl);
|
||||||
|
final boolean stage1Applies = !payableIsPaid;
|
||||||
|
if (stage1Applies) {
|
||||||
|
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
||||||
|
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 3: out to the members, one at a time.
|
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
|
||||||
|
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
|
||||||
|
// from there instead — debiting the paid account would take the money out of an account
|
||||||
|
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
|
||||||
|
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
||||||
|
|
||||||
|
// Stage 2: out to the members, one at a time.
|
||||||
BigDecimal distributed = BigDecimal.ZERO;
|
BigDecimal distributed = BigDecimal.ZERO;
|
||||||
for (final Map<String, Object> row : due) {
|
for (final Map<String, Object> row : due) {
|
||||||
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
||||||
@@ -208,7 +265,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||||
newBalance);
|
newBalance);
|
||||||
|
|
||||||
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference,
|
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
|
||||||
accountId);
|
accountId);
|
||||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||||
accountId, transactionId);
|
accountId, transactionId);
|
||||||
@@ -223,12 +280,22 @@ public class SavingsPooledInterestPostingService {
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
||||||
|
|
||||||
|
// Both interest columns move together, and that is not a duplicate.
|
||||||
|
//
|
||||||
|
// total_interest_earned_derived is the interest the account has earned to date;
|
||||||
|
// total_interest_posted_derived is how much of that has been paid into the balance.
|
||||||
|
// Screens and validate()'s own check read the unposted interest as earned - posted, so
|
||||||
|
// raising only `posted` drives that difference negative: an account paid 26.52 with
|
||||||
|
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
|
||||||
|
// showed exactly that. The accrual writes pending interest to the ledger and never to
|
||||||
|
// `earned`, so this posting is the only place `earned` is realised.
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_savings_account
|
UPDATE m_savings_account
|
||||||
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
|
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
||||||
|
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", amount, amount, accountId);
|
""", amount, amount, amount, accountId);
|
||||||
|
|
||||||
distributed = distributed.add(amount);
|
distributed = distributed.add(amount);
|
||||||
}
|
}
|
||||||
@@ -288,7 +355,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
||||||
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
||||||
final long transactionId) {
|
final long transactionId) {
|
||||||
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
|
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||||
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +364,22 @@ public class SavingsPooledInterestPostingService {
|
|||||||
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One journal entry leg.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Fineract convention: {@code type_enum} 2 = DEBIT, 1 = CREDIT — {@code JournalEntryType}
|
||||||
|
* declares {@code CREDIT(1)} first. This read {@code debit ? 1 : 2} and so signed every leg the
|
||||||
|
* wrong way round: the member's savings-control credit was stored as a debit and the expense
|
||||||
|
* debit as a credit. Nothing downstream caught it — the run's own pooled-equals-distributed check
|
||||||
|
* compares Java values and never looks at {@code type_enum}, and migration 0531 disables the
|
||||||
|
* stock posting job, so this is the only path writing savings interest to the ledger and there
|
||||||
|
* was no correct one alongside to disagree with it.
|
||||||
|
*/
|
||||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||||
final Long transactionId) {
|
final Long transactionId) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||||
@@ -309,7 +389,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount,
|
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
|
||||||
debit ? "Savings interest posting" : "Savings interest posted",
|
debit ? "Savings interest posting" : "Savings interest posted",
|
||||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||||
systemUserId(), systemUserId(), date);
|
systemUserId(), systemUserId(), date);
|
||||||
@@ -325,16 +405,39 @@ public class SavingsPooledInterestPostingService {
|
|||||||
return ids.isEmpty() ? 1L : ids.get(0);
|
return ids.isEmpty() ? 1L : ids.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long glAccountId(final String glCode) {
|
/**
|
||||||
return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode);
|
* The GL account the product maps under one of Fineract's fixed financial account types, as set
|
||||||
|
* on the product's Accounting tab.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A missing mapping is reported against the product rather than surfacing as an
|
||||||
|
* {@code EmptyResultDataAccessException} from inside a batch that has already posted other
|
||||||
|
* products — the message has to say which product to go and configure.
|
||||||
|
*/
|
||||||
|
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||||
|
final Long found = optionalMappedGlAccount(productId, financialAccountType);
|
||||||
|
if (found == null) {
|
||||||
|
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
|
||||||
|
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
|
||||||
|
}
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
/**
|
||||||
return jdbcTemplate.queryForObject("""
|
* One row of the product's Accounting tab, or null where the bank has not filled it in.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Separate from {@link #mappedGlAccount} because not every row is required: Interest Payable is
|
||||||
|
* offered on the product screen but a product configured before it existed has none, and that
|
||||||
|
* must fall through to the next choice rather than stopping the product from being paid.
|
||||||
|
*/
|
||||||
|
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
|
||||||
|
final List<Long> found = jdbcTemplate.queryForList("""
|
||||||
SELECT gl_account_id FROM acc_product_mapping
|
SELECT gl_account_id FROM acc_product_mapping
|
||||||
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", Long.class, productId, financialAccountType);
|
""", Long.class, productId, financialAccountType);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,7 +480,13 @@ public class SavingsPooledInterestPostingService {
|
|||||||
return found.isEmpty() ? null : found.get(0);
|
return found.isEmpty() ? null : found.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Long firstNonNull(final Long preferred, final Long fallback) {
|
/** The first configured account of those given, in order of preference. */
|
||||||
return preferred != null ? preferred : fallback;
|
private static Long firstNonNull(final Long... candidates) {
|
||||||
|
for (final Long candidate : candidates) {
|
||||||
|
if (candidate != null) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-6
@@ -31,6 +31,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.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
@@ -290,16 +291,33 @@ public class SavingsDailyInterestApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
|
/**
|
||||||
|
* Runs a day by hand, for a first backfill or after fixing a failed night.
|
||||||
|
*
|
||||||
|
* Defaults to the business date, and refuses to run past it. Accruing or posting for a date the
|
||||||
|
* bank has not reached writes interest into the books for days that have not happened: the
|
||||||
|
* member's balance goes up today for a quarter that ends next month, and the journal entries
|
||||||
|
* carry that future date. Worse, the business date can be moved back afterwards — for a test, or
|
||||||
|
* because it was advanced by mistake — and then the ledger holds a posting dated beyond the day
|
||||||
|
* the bank is working, which nothing reconciles against.
|
||||||
|
*
|
||||||
|
* To post for a later date, move the business date there first. That is the same order the
|
||||||
|
* nightly run follows, and it keeps the books and the business date in step.
|
||||||
|
*/
|
||||||
@POST
|
@POST
|
||||||
@Path("/run")
|
@Path("/run")
|
||||||
public String run(@QueryParam("date") final String dateParam) {
|
public String run(@QueryParam("date") final String dateParam) {
|
||||||
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
final LocalDate runFor = dateParam == null || dateParam.isBlank() ? businessDate : LocalDate.parse(dateParam);
|
||||||
: LocalDate.parse(dateParam);
|
if (runFor.isAfter(businessDate)) {
|
||||||
orchestrator.runForDate(businessDate);
|
throw new PlatformDataIntegrityException("error.msg.savings.interest.run.after.business.date",
|
||||||
|
"Cannot run savings interest for " + runFor + ": the business date is " + businessDate
|
||||||
|
+ ". Move the business date forward first — interest cannot be accrued or posted for a day"
|
||||||
|
+ " the bank has not reached.");
|
||||||
|
}
|
||||||
|
orchestrator.runForDate(runFor);
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("ranFor", businessDate.toString());
|
result.put("ranFor", runFor.toString());
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -43,6 +43,9 @@ import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
|||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
|
||||||
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -59,6 +62,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
|
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
private final KycRestrictionGuard kycRestrictionGuard;
|
||||||
|
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository,
|
public SavingsAccountDomainServiceJpa(final SavingsAccountRepositoryWrapper savingsAccountRepository,
|
||||||
@@ -67,7 +73,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
final JournalEntryWritePlatformService journalEntryWritePlatformService,
|
final JournalEntryWritePlatformService journalEntryWritePlatformService,
|
||||||
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
|
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
|
||||||
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||||
final BusinessEventNotifierService businessEventNotifierService) {
|
final BusinessEventNotifierService businessEventNotifierService,
|
||||||
|
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService,
|
||||||
|
final KycRestrictionGuard kycRestrictionGuard) {
|
||||||
this.savingsAccountRepository = savingsAccountRepository;
|
this.savingsAccountRepository = savingsAccountRepository;
|
||||||
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
|
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
|
||||||
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
|
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
|
||||||
@@ -76,6 +84,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository;
|
this.depositAccountOnHoldTransactionRepository = depositAccountOnHoldTransactionRepository;
|
||||||
this.businessEventNotifierService = businessEventNotifierService;
|
this.businessEventNotifierService = businessEventNotifierService;
|
||||||
|
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||||
|
this.voucherService = voucherService;
|
||||||
|
this.kycRestrictionGuard = kycRestrictionGuard;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -86,6 +97,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
context.authenticatedUser();
|
context.authenticatedUser();
|
||||||
account.validateForAccountBlock();
|
account.validateForAccountBlock();
|
||||||
account.validateForDebitBlock();
|
account.validateForDebitBlock();
|
||||||
|
// Every withdrawal in the system arrives here — the clerk's counter, the core API, an
|
||||||
|
// account transfer, a standing instruction — so the dormancy and re-KYC ladders are
|
||||||
|
// enforced at this one point rather than at each of those doors.
|
||||||
|
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.DEBIT);
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate();
|
final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate();
|
||||||
@@ -148,6 +163,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
|
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As on the deposit path: the ledger's row must follow the balance out, or the account goes on
|
||||||
|
// earning on money it no longer holds until tonight's run corrects it.
|
||||||
|
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
|
||||||
|
|
||||||
|
// The debit voucher for the withdrawal, including any withdrawal fee the posting collected —
|
||||||
|
// one slip, every leg, the way a payment voucher is written at a counter.
|
||||||
|
if (!transactionBooleanValues.isSkipAccountingPostings()) {
|
||||||
|
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||||
|
withdrawal.getId(), context.authenticatedUser(), VoucherService.PREFIX_DEBIT);
|
||||||
|
}
|
||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
|
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
|
||||||
return withdrawal;
|
return withdrawal;
|
||||||
}
|
}
|
||||||
@@ -188,6 +214,12 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
context.authenticatedUser();
|
context.authenticatedUser();
|
||||||
account.validateForAccountBlock();
|
account.validateForAccountBlock();
|
||||||
account.validateForCreditBlock();
|
account.validateForCreditBlock();
|
||||||
|
// Money coming in is a credit however it arrives — over the counter, or transferred from
|
||||||
|
// another account. The restriction is on the customer taking money out or moving it away,
|
||||||
|
// not on anyone paying them: a salary credit, a benefit transfer or a relative sending
|
||||||
|
// funds must land while the customer is being chased for their re-KYC, and it cannot be
|
||||||
|
// used to spirit the balance anywhere, since the debit side stays shut.
|
||||||
|
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||||
|
|
||||||
// Global configurations
|
// Global configurations
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
@@ -238,6 +270,21 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
if (!skipAccountingPostings) {
|
if (!skipAccountingPostings) {
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
|
||||||
}
|
}
|
||||||
|
// The daily-interest ledger states the balance as at its last row, which is yesterday's until
|
||||||
|
// tonight's run. A closure raised in between reads that row, so money paid in today would earn
|
||||||
|
// nothing and money paid out would go on earning. Bringing the row up to date here keeps what
|
||||||
|
// a closure reads in step with what the account holds.
|
||||||
|
savingsDailyInterestService.refreshAfterBalanceChange(account.getId(), today);
|
||||||
|
|
||||||
|
// The credit voucher for the deposit. Raised from the journal entries the posting just wrote,
|
||||||
|
// so a deposit that also collected a charge carries every leg on ONE slip — which is what the
|
||||||
|
// member signs and the branch files. This is the path every counter deposit takes (cashier,
|
||||||
|
// clerk, transfer in), and it raised no voucher at all before.
|
||||||
|
if (!skipAccountingPostings) {
|
||||||
|
voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_DEPOSIT, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||||
|
deposit.getId(), context.authenticatedUser(), VoucherService.PREFIX_CREDIT);
|
||||||
|
}
|
||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
|
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
|
||||||
return deposit;
|
return deposit;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one definition of when a savings account was last known to be in live use, for the dormancy
|
||||||
|
* ladder to measure from.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* It is written here once because it is read from six places — the nightly job's three selection
|
||||||
|
* queries, the account read used by the counter screen, the account list, and the dormancy report
|
||||||
|
* — and those places disagreeing is what makes an account inactive on one screen and active on the
|
||||||
|
* next. A change to the rule belongs in this file and nowhere else.
|
||||||
|
*
|
||||||
|
* <h3>What counts</h3>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A completed re-KYC, and nothing else. RBI's Master Direction on KYC makes periodic re-verification
|
||||||
|
* the thing that keeps an account operative, and a lapsed account operable again only once the
|
||||||
|
* customer's identity has been established afresh; a cash deposit at the counter is not that
|
||||||
|
* verification, so it no longer revives an account here.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The account's activation stands in for a client who has never been re-verified, so a newly opened
|
||||||
|
* account does not start life a year behind.
|
||||||
|
*/
|
||||||
|
public final class DormancyClock {
|
||||||
|
|
||||||
|
private DormancyClock() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL for the date the dormancy ladder measures from, as an expression over the aliases
|
||||||
|
* {@code sa} (m_savings_account) and {@code c} (m_client).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a
|
||||||
|
* WHERE clause without having to add a join it may already have — and so a client with no KYC
|
||||||
|
* record at all falls back to the activation date rather than dropping out of the result.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Null before the account is ever activated: {@code GREATEST} in Postgres ignores a null
|
||||||
|
* argument rather than propagating it, so without this guard an account still awaiting approval
|
||||||
|
* (activation date not yet set) would report the client's own KYC re-verification date as its
|
||||||
|
* "last live" date — a date that has nothing to do with this account, which has never been live
|
||||||
|
* at all.
|
||||||
|
*/
|
||||||
|
public static final String LAST_LIVE_ON = """
|
||||||
|
CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE
|
||||||
|
GREATEST(
|
||||||
|
sa.activatedon_date,
|
||||||
|
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
|
||||||
|
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
|
||||||
|
sa.activatedon_date))
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
}
|
||||||
+74
-7
@@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
|||||||
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
|
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
|
||||||
this.context.authenticatedUser();
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) "
|
final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
|
||||||
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
|
|
||||||
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
|
|
||||||
|
|
||||||
final StringBuilder sql = new StringBuilder();
|
final StringBuilder sql = new StringBuilder();
|
||||||
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
|
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
|
||||||
@@ -85,19 +83,57 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
|||||||
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
|
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
|
||||||
// and the absence of one is exactly what the report has to show.
|
// and the absence of one is exactly what the report has to show.
|
||||||
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
|
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
|
||||||
// Only the three states the ladder sets. A blocked or a plainly active account is not
|
// An account belongs on these reports once the ladder has stamped it, or once the days
|
||||||
// dormant and does not belong on these reports.
|
// have earned it — whichever comes first.
|
||||||
sql.append("where sa.sub_status_enum in (?, ?, ?) ");
|
//
|
||||||
|
// The stamp is applied by the nightly job, so an account that crossed a threshold today
|
||||||
|
// still carries sub_status 0 until that job next runs, and the report showed nothing at
|
||||||
|
// all: an account 23 days past its dormancy date was absent from a report whose whole
|
||||||
|
// purpose is to list it. The elapsed count is already computed above for the daysIdle
|
||||||
|
// column and uses the job's own definition of a customer transaction, so reading the
|
||||||
|
// thresholds off the same product row here cannot disagree with what the job will do.
|
||||||
|
//
|
||||||
|
// Escheat is matched on the stamp alone. Unlike the other two it is not a label but a
|
||||||
|
// movement of money — the job closes the account and takes the balance out — and listing
|
||||||
|
// an account as escheated before that happened would report money as remitted that is
|
||||||
|
// still sitting in the account.
|
||||||
|
sql.append("where (sa.sub_status_enum in (?, ?, ?) ");
|
||||||
|
sql.append(" or (sp.is_dormancy_tracking_active = true and sa.status_enum = 300 ");
|
||||||
|
sql.append(" and (sp.days_to_inactive > 0 and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||||
|
.append(" >= sp.days_to_inactive))) ");
|
||||||
|
|
||||||
final List<Object> params = new ArrayList<>();
|
final List<Object> params = new ArrayList<>();
|
||||||
params.add(DateUtils.getBusinessLocalDate());
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
params.add(SUB_STATUS_INACTIVE);
|
params.add(SUB_STATUS_INACTIVE);
|
||||||
params.add(SUB_STATUS_DORMANT);
|
params.add(SUB_STATUS_DORMANT);
|
||||||
params.add(SUB_STATUS_ESCHEAT);
|
params.add(SUB_STATUS_ESCHEAT);
|
||||||
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
|
|
||||||
if (subStatus != null) {
|
if (subStatus != null) {
|
||||||
|
// Match the stamp or the stage the elapsed days have earned, so a stage filter picks up
|
||||||
|
// the same accounts the unfiltered report shows.
|
||||||
|
if (subStatus == SUB_STATUS_ESCHEAT) {
|
||||||
sql.append("and sa.sub_status_enum = ? ");
|
sql.append("and sa.sub_status_enum = ? ");
|
||||||
params.add(subStatus);
|
params.add(subStatus);
|
||||||
|
} else if (subStatus == SUB_STATUS_DORMANT) {
|
||||||
|
// Past the escheat threshold but not yet escheated still counts as dormant: the
|
||||||
|
// account is dormant until the job closes it and moves the money, and it has to
|
||||||
|
// appear somewhere until then.
|
||||||
|
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_dormancy > 0 and ")
|
||||||
|
.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" >= sp.days_to_dormancy)) ");
|
||||||
|
params.add(subStatus);
|
||||||
|
params.add(SUB_STATUS_ESCHEAT);
|
||||||
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
|
} else {
|
||||||
|
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_inactive > 0 and ")
|
||||||
|
.append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||||
|
.append(" >= sp.days_to_inactive and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||||
|
.append(" < sp.days_to_dormancy)) ");
|
||||||
|
params.add(subStatus);
|
||||||
|
params.add(SUB_STATUS_DORMANT);
|
||||||
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (officeName != null && !officeName.isBlank()) {
|
if (officeName != null && !officeName.isBlank()) {
|
||||||
sql.append("and o.name = ? ");
|
sql.append("and o.name = ? ");
|
||||||
@@ -114,9 +150,19 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
final int subStatus = rs.getInt("subStatus");
|
|
||||||
final BigDecimal balance = rs.getBigDecimal("balance");
|
final BigDecimal balance = rs.getBigDecimal("balance");
|
||||||
final long daysIdle = rs.getLong("daysIdle");
|
final long daysIdle = rs.getLong("daysIdle");
|
||||||
|
// The stage the elapsed days put the account at, in place of the stamp — the same rule
|
||||||
|
// the WHERE clause selects on, so a row cannot appear on the report under one stage and
|
||||||
|
// be labelled another.
|
||||||
|
//
|
||||||
|
// The days replace the stamp rather than merely raising it, because the stamp can also
|
||||||
|
// be ahead: a customer transaction resets the clock, and an escheat that has not
|
||||||
|
// happened yet leaves an account reported as dormant, which is what it still is.
|
||||||
|
// Escheat itself is the exception and keeps its stamp, since the job has by then closed
|
||||||
|
// the account and moved the balance out.
|
||||||
|
final int stamped = rs.getInt("subStatus");
|
||||||
|
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
|
||||||
|
|
||||||
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
|
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
|
||||||
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
|
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
|
||||||
@@ -134,6 +180,27 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
|||||||
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
|
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage the elapsed days have earned, whatever the job has stamped so far.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Escheat is never earned this way. Unlike the other two it is not a label but a movement
|
||||||
|
* of money — the job closes the account and takes the balance out — so an account only
|
||||||
|
* counts as escheated once that has actually happened, and until then it is reported as
|
||||||
|
* dormant, which is what it still is.
|
||||||
|
*/
|
||||||
|
private int earnedStage(final ResultSet rs, final long daysIdle) throws SQLException {
|
||||||
|
final Long toDormancy = nullableLong(rs, "daysToDormancy");
|
||||||
|
if (toDormancy != null && toDormancy > 0 && daysIdle >= toDormancy) {
|
||||||
|
return SUB_STATUS_DORMANT;
|
||||||
|
}
|
||||||
|
final Long toInactive = nullableLong(rs, "daysToInactive");
|
||||||
|
if (toInactive != null && toInactive > 0 && daysIdle >= toInactive) {
|
||||||
|
return SUB_STATUS_INACTIVE;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
|
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
|
||||||
final long value = rs.getLong(column);
|
final long value = rs.getLong(column);
|
||||||
return rs.wasNull() ? null : value;
|
return rs.wasNull() ? null : value;
|
||||||
|
|||||||
+205
-23
@@ -22,9 +22,11 @@ import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsCons
|
|||||||
import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD;
|
import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -66,6 +68,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
|
|||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
|
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
|
||||||
@@ -83,6 +86,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.jdbc.core.ResultSetExtractor;
|
import org.springframework.jdbc.core.ResultSetExtractor;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService {
|
public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService {
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -104,14 +108,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
private final SavingsAccountAssembler savingAccountAssembler;
|
private final SavingsAccountAssembler savingAccountAssembler;
|
||||||
|
|
||||||
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
||||||
|
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||||
|
|
||||||
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
|
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
|
||||||
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
|
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
|
||||||
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
|
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||||
|
final SavingsDailyInterestService savingsDailyInterestService) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.sqlGenerator = sqlGenerator;
|
this.sqlGenerator = sqlGenerator;
|
||||||
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
||||||
|
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||||
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
|
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
|
||||||
this.transactionsMapper = new SavingsAccountTransactionsMapper();
|
this.transactionsMapper = new SavingsAccountTransactionsMapper();
|
||||||
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
|
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
|
||||||
@@ -122,6 +129,98 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
this.savingAccountAssembler = savingAccountAssembler;
|
this.savingAccountAssembler = savingAccountAssembler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-accrues one account's interest ledger if it is not already at the business date.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Guarded by a cheap read so the common case costs one indexed lookup and no write: through a
|
||||||
|
* normal day the nightly run has already brought the row to today and there is nothing to do. The
|
||||||
|
* refresh only fires when the row is genuinely behind — after the business date moves, or on an
|
||||||
|
* account whose row the nightly run has not reached yet.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A row dated <em>after</em> the business date is refreshed too, not skipped. That is the case
|
||||||
|
* where the business date has been moved back, and the ledger has to come down to the earlier
|
||||||
|
* date just as it goes up to a later one.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only savings accounts carry a row here; fixed and recurring deposits accrue through core
|
||||||
|
* Fineract and are left alone by the refresh itself.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The dormancy sub-status the elapsed days have earned, whatever the column currently says.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The nightly job is what stamps the column, so between an account crossing a threshold and
|
||||||
|
* that job next running the column understates where the account actually stands — by a day in
|
||||||
|
* the ordinary case, by longer after an outage or when the business date moves by more than a
|
||||||
|
* day. Screens that read the column alone then tell the counter an account is fine when the
|
||||||
|
* transaction is about to be refused.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Mirrors the job's own thresholds, which are the product's, and is only ever used to raise the
|
||||||
|
* reported status, never to lower one the job has already set.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The dormancy sub-status to report: the one the elapsed days say, in place of the one the
|
||||||
|
* nightly job last stamped.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Both directions, not only upward. Upward closes the gap between an account crossing a
|
||||||
|
* threshold and the job next running. Downward matters because the stamp can outlive the
|
||||||
|
* reason for it: a customer transaction resets the clock, and moving the business date back —
|
||||||
|
* which is how this is tested — leaves a DORMANT stamp on an account only 379 days idle, which
|
||||||
|
* is inactive. Either way the days are the truth and the stamp is a cache of it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only the three dormancy states are touched. A blocked account, or one the job has escheated,
|
||||||
|
* keeps what it has: those are not positions on this ladder, and escheat in particular has
|
||||||
|
* moved money and closed the account, which no recomputation here can undo.
|
||||||
|
*/
|
||||||
|
private static int reportedDormancy(final Integer stamped, final LocalDate lastActive, final LocalDate businessDate,
|
||||||
|
final Integer daysToInactive, final Integer daysToDormancy) {
|
||||||
|
final int current = stamped == null ? SavingsAccountSubStatusEnum.NONE.getValue() : stamped;
|
||||||
|
if (current >= SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return earnedSubStatus(lastActive, businessDate, daysToInactive, daysToDormancy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int earnedSubStatus(final LocalDate lastActive, final LocalDate businessDate, final Integer daysToInactive,
|
||||||
|
final Integer daysToDormancy) {
|
||||||
|
if (lastActive == null) {
|
||||||
|
return SavingsAccountSubStatusEnum.NONE.getValue();
|
||||||
|
}
|
||||||
|
final long idle = ChronoUnit.DAYS.between(lastActive, businessDate);
|
||||||
|
// Escheat is deliberately not anticipated. Unlike the other two it is not a label but a
|
||||||
|
// movement of money — the job closes the account and transfers the balance to the escheat
|
||||||
|
// liability — and reporting it before that has happened would show a closed account still
|
||||||
|
// holding its balance. It stays whatever the job has actually done.
|
||||||
|
if (daysToDormancy != null && daysToDormancy > 0 && idle >= daysToDormancy) {
|
||||||
|
return SavingsAccountSubStatusEnum.DORMANT.getValue();
|
||||||
|
}
|
||||||
|
if (daysToInactive != null && daysToInactive > 0 && idle >= daysToInactive) {
|
||||||
|
return SavingsAccountSubStatusEnum.INACTIVE.getValue();
|
||||||
|
}
|
||||||
|
return SavingsAccountSubStatusEnum.NONE.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshInterestLedger(final Long accountId) {
|
||||||
|
try {
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final List<LocalDate> ledgerDate = this.jdbcTemplate.queryForList(
|
||||||
|
"select balance_date from m_savings_daily_interest where savings_account_id = ?", LocalDate.class, accountId);
|
||||||
|
if (!ledgerDate.isEmpty() && DateUtils.isEqual(ledgerDate.get(0), businessDate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.savingsDailyInterestService.refreshAfterBalanceChange(accountId, businessDate);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// Never break the read. The account must still open if its ledger cannot be refreshed;
|
||||||
|
// the nightly run will pick it up.
|
||||||
|
log.warn("Could not refresh the interest ledger for savings account {} before reading it", accountId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) {
|
public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) {
|
||||||
|
|
||||||
@@ -269,6 +368,21 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
@Override
|
@Override
|
||||||
public SavingsAccountData retrieveOne(final Long accountId) {
|
public SavingsAccountData retrieveOne(final Long accountId) {
|
||||||
|
|
||||||
|
// Bring the interest ledger up to the business date before reading it.
|
||||||
|
//
|
||||||
|
// The accrual normally runs once a night, so between the business date moving and the next
|
||||||
|
// run the ledger is behind and this screen reports interest as of the older date. Moving the
|
||||||
|
// business date to 30 September and opening the account showed the interest accrued to the
|
||||||
|
// 9th, and moving it back showed the higher figure — the interest did not follow the date in
|
||||||
|
// either direction, which is what made it look broken.
|
||||||
|
//
|
||||||
|
// refreshAfterBalanceChange is built for exactly this: it rewrites the account's row from the
|
||||||
|
// day before it rather than from itself, so calling it repeatedly converges instead of
|
||||||
|
// compounding, it creates the row if the account has none, and it never propagates a failure
|
||||||
|
// to its caller. That last property is what makes it safe on a read path — a ledger that
|
||||||
|
// cannot be refreshed must not stop the account from being displayed.
|
||||||
|
refreshInterestLedger(accountId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?";
|
final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?";
|
||||||
|
|
||||||
@@ -379,7 +493,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
||||||
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
||||||
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
||||||
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
|
// Interest earned counts what the daily ledger has accrued but not yet paid.
|
||||||
|
//
|
||||||
|
// total_interest_earned_derived only moves when interest is posted, so between two
|
||||||
|
// posting dates it says the account has earned nothing since the last payout. The screen
|
||||||
|
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
|
||||||
|
// (or a stale figure) on accounts the accrual had been building up for weeks — one
|
||||||
|
// account had accrued 1,024.11 while the screen read 16.29.
|
||||||
|
//
|
||||||
|
// m_savings_daily_interest holds one row per account carrying the interest accrued in
|
||||||
|
// the current cycle, which is exactly the unposted amount. Adding it to what has been
|
||||||
|
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
|
||||||
|
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
|
||||||
|
// reading their own column exactly as before.
|
||||||
|
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
|
||||||
|
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
|
||||||
|
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
|
||||||
|
// Adding the cumulative figure whole would count that payment twice and report the
|
||||||
|
// account as having earned 24.90.
|
||||||
|
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
|
||||||
|
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
|
||||||
|
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
|
||||||
|
+ "as totalInterestEarned, ");
|
||||||
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
||||||
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
||||||
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
||||||
@@ -397,11 +532,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
||||||
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
|
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
|
||||||
sqlBuilder.append("tg.id as taxGroupId, ");
|
sqlBuilder.append("tg.id as taxGroupId, ");
|
||||||
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
|
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
|
||||||
sqlBuilder.append("from m_savings_account_transaction as sat ");
|
|
||||||
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
|
|
||||||
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
|
|
||||||
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
|
|
||||||
sqlBuilder.append("sp.id as productId, ");
|
sqlBuilder.append("sp.id as productId, ");
|
||||||
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
||||||
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
||||||
@@ -521,8 +652,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
|
|
||||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
||||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||||
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
|
||||||
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
||||||
final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive");
|
final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive");
|
||||||
final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive");
|
final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive");
|
||||||
@@ -534,6 +664,20 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
|
|
||||||
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
||||||
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
||||||
|
// Report the sub-status the elapsed days have earned, not only the one the
|
||||||
|
// nightly job has got round to stamping.
|
||||||
|
//
|
||||||
|
// The job stamps DORMANT at midnight, so an account that crossed the
|
||||||
|
// threshold during the day still reads as active until the next run — and
|
||||||
|
// that is what the counter screen showed the clerk: "Status: Active", no
|
||||||
|
// dormancy banner, on an account 23 days past its dormancy date. The clerk
|
||||||
|
// filled in the whole slip before anything refused it.
|
||||||
|
//
|
||||||
|
// The thresholds and the last-active date are already read above for the
|
||||||
|
// countdown, so this only reuses them. It is a read: the account row is not
|
||||||
|
// written here, and the job remains what actually stamps the column.
|
||||||
|
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
|
||||||
|
numDaysToDormancy);
|
||||||
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||||
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate,
|
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate,
|
||||||
lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
||||||
@@ -547,6 +691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
lastActiveTransactionDate.plusDays(numDaysToInactive));
|
lastActiveTransactionDate.plusDays(numDaysToInactive));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||||
final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate");
|
final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate");
|
||||||
final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate");
|
final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate");
|
||||||
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
||||||
@@ -596,7 +741,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
||||||
LocalDate lastInterestCalculationDate = null;
|
LocalDate lastInterestCalculationDate = null;
|
||||||
if (totalInterestEarned != null) {
|
if (totalInterestEarned != null) {
|
||||||
|
// Floored at zero: this is unposted interest, and it cannot be negative.
|
||||||
|
//
|
||||||
|
// Earned and posted are two separate columns that a failed or reverted
|
||||||
|
// posting can leave inconsistent, and the difference going negative used to
|
||||||
|
// surface on the account screen as "Overdraft Interest Not Posted -25.98" —
|
||||||
|
// the screen shows this same field under that label whenever it is negative,
|
||||||
|
// on accounts with no overdraft at all.
|
||||||
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
||||||
|
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
|
||||||
|
interestNotPosted = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +985,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
||||||
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
||||||
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
||||||
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
|
// Interest earned counts what the daily ledger has accrued but not yet paid.
|
||||||
|
//
|
||||||
|
// total_interest_earned_derived only moves when interest is posted, so between two
|
||||||
|
// posting dates it says the account has earned nothing since the last payout. The screen
|
||||||
|
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
|
||||||
|
// (or a stale figure) on accounts the accrual had been building up for weeks — one
|
||||||
|
// account had accrued 1,024.11 while the screen read 16.29.
|
||||||
|
//
|
||||||
|
// m_savings_daily_interest holds one row per account carrying the interest accrued in
|
||||||
|
// the current cycle, which is exactly the unposted amount. Adding it to what has been
|
||||||
|
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
|
||||||
|
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
|
||||||
|
// reading their own column exactly as before.
|
||||||
|
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
|
||||||
|
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
|
||||||
|
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
|
||||||
|
// Adding the cumulative figure whole would count that payment twice and report the
|
||||||
|
// account as having earned 24.90.
|
||||||
|
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
|
||||||
|
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
|
||||||
|
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
|
||||||
|
+ "as totalInterestEarned, ");
|
||||||
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
||||||
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
||||||
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
||||||
@@ -849,11 +1025,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
||||||
sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
|
sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
|
||||||
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, ");
|
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, ");
|
||||||
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
|
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
|
||||||
sqlBuilder.append("from m_savings_account_transaction as sat ");
|
|
||||||
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
|
|
||||||
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
|
|
||||||
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
|
|
||||||
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
||||||
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
||||||
sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, ");
|
sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, ");
|
||||||
@@ -948,8 +1120,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
||||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||||
|
|
||||||
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
|
||||||
final String reasonForBlock = rs.getString("reasonForBlock");
|
final String reasonForBlock = rs.getString("reasonForBlock");
|
||||||
|
|
||||||
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
||||||
@@ -963,6 +1134,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
|
|
||||||
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
||||||
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
||||||
|
// The elapsed days decide, not only the column the nightly job stamps — see
|
||||||
|
// earnedSubStatus. This is the mapper behind the single-account read, which is what
|
||||||
|
// the counter screen loads when a clerk searches an account, so without this it
|
||||||
|
// showed "Active" on an account already past its dormancy date and the clerk filled
|
||||||
|
// in the whole slip before anything refused it.
|
||||||
|
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
|
||||||
|
numDaysToDormancy);
|
||||||
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||||
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
||||||
}
|
}
|
||||||
@@ -973,6 +1151,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive));
|
daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||||
|
|
||||||
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
||||||
final String submittedByUsername = rs.getString("submittedByUsername");
|
final String submittedByUsername = rs.getString("submittedByUsername");
|
||||||
@@ -1092,7 +1271,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
||||||
LocalDate lastInterestCalculationDate = null;
|
LocalDate lastInterestCalculationDate = null;
|
||||||
if (totalInterestEarned != null) {
|
if (totalInterestEarned != null) {
|
||||||
|
// Floored at zero, for the same reason as the summary mapper above: unposted
|
||||||
|
// interest cannot be negative, and a negative value reaches the account screen
|
||||||
|
// labelled "Overdraft Interest Not Posted" even on accounts with no overdraft.
|
||||||
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
||||||
|
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
|
||||||
|
interestNotPosted = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,10 +1669,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) ");
|
sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) ");
|
||||||
sql.append(" where sa.status_enum = 300 ");
|
sql.append(" where sa.status_enum = 300 ");
|
||||||
sql.append(" and sa.sub_status_enum = 0 ");
|
sql.append(" and sa.sub_status_enum = 0 ");
|
||||||
String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) "
|
sql.append(" and ").append(sqlGenerator.dateDiff("?", DormancyClock.LAST_LIVE_ON)).append(" >= sp.days_to_inactive ");
|
||||||
+ "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false"
|
|
||||||
+ " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)";
|
|
||||||
sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive ");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class);
|
ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class);
|
||||||
@@ -1509,7 +1691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sql.append(" where sa.status_enum = 300 ");
|
sql.append(" where sa.status_enum = 300 ");
|
||||||
sql.append(" and sa.sub_status_enum = 100 ");
|
sql.append(" and sa.sub_status_enum = 100 ");
|
||||||
sql.append(" and " + sqlGenerator.dateDiff("?",
|
sql.append(" and " + sqlGenerator.dateDiff("?",
|
||||||
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
|
DormancyClock.LAST_LIVE_ON)
|
||||||
+ " ");
|
+ " ");
|
||||||
sql.append(" >= sp.days_to_dormancy ");
|
sql.append(" >= sp.days_to_dormancy ");
|
||||||
|
|
||||||
@@ -1533,7 +1715,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sql.append(" where sa.status_enum = 300 ");
|
sql.append(" where sa.status_enum = 300 ");
|
||||||
sql.append(" and sa.sub_status_enum = 200 ");
|
sql.append(" and sa.sub_status_enum = 200 ");
|
||||||
sql.append(" and " + sqlGenerator.dateDiff("?",
|
sql.append(" and " + sqlGenerator.dateDiff("?",
|
||||||
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
|
DormancyClock.LAST_LIVE_ON)
|
||||||
+ " ");
|
+ " ");
|
||||||
sql.append(" >= sp.days_to_escheat ");
|
sql.append(" >= sp.days_to_escheat ");
|
||||||
|
|
||||||
|
|||||||
+28
-38
@@ -363,9 +363,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Money arriving at the counter: cash is debited and the member's savings control account
|
// The voucher itself is raised by handleDeposit() above, off the journal entries it posted —
|
||||||
// is credited.
|
// one slip covering every leg, including any charge collected with the deposit. Read it back
|
||||||
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true);
|
// here only to return it to the caller.
|
||||||
|
final String depositVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(deposit.getId());
|
||||||
if (depositVoucherNo != null) {
|
if (depositVoucherNo != null) {
|
||||||
changes.put("voucherNo", depositVoucherNo);
|
changes.put("voucherNo", depositVoucherNo);
|
||||||
}
|
}
|
||||||
@@ -446,9 +447,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Money leaving the member over the counter: their savings control account is debited and
|
// As on the deposit path: handleWithdrawal() raised the slip; this only reads it back.
|
||||||
// the till that pays it out is credited.
|
final String withdrawalVoucherNo = this.voucherService.findVoucherNoForSavingsTransaction(withdrawal.getId());
|
||||||
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
|
|
||||||
if (withdrawalVoucherNo != null) {
|
if (withdrawalVoucherNo != null) {
|
||||||
changes.put("voucherNo", withdrawalVoucherNo);
|
changes.put("voucherNo", withdrawalVoucherNo);
|
||||||
}
|
}
|
||||||
@@ -1513,6 +1513,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
|
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
|
// A charge taken from the balance is a journal voucher, not a debit voucher: no cash crosses
|
||||||
|
// the counter, the member's balance simply moves to an income head. A branch files these in
|
||||||
|
// the JV book, which is why the series differs from a counter withdrawal that collects a fee.
|
||||||
|
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_CHARGE, VoucherService.REF_SAVINGS_TRANSACTION,
|
||||||
|
chargeTransaction.getId(), this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL);
|
||||||
|
|
||||||
return chargeTransaction;
|
return chargeTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1766,6 +1772,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
account.escheat(appuserRepository.fetchSystemUser());
|
account.escheat(appuserRepository.fetchSystemUser());
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
||||||
|
|
||||||
|
// A journal voucher for the balance moved to the escheat liability. Raised under the system
|
||||||
|
// user, not a branch user: this runs from the dormant-accounts batch with nobody at a
|
||||||
|
// counter, and the register has to say so.
|
||||||
|
final AppUser systemUser = appuserRepository.fetchSystemUser();
|
||||||
|
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
|
||||||
|
if (transaction.getId() != null && !existingTransactionIds.contains(transaction.getId())
|
||||||
|
&& transaction.isAmountOnHoldNotReleased() == false && transaction.isNotReversed()) {
|
||||||
|
this.voucherService.createVoucherFromJournalEntries(VoucherService.TYPE_ESCHEAT, REF_SAVINGS_TRANSACTION,
|
||||||
|
transaction.getId(), systemUser, VoucherService.PREFIX_JOURNAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppUser getAppUserIfPresent() {
|
private AppUser getAppUserIfPresent() {
|
||||||
@@ -2080,46 +2098,18 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
||||||
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT,
|
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
|
||||||
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
||||||
.withDescription("Interest posted to " + accountNo),
|
.withDescription("Interest posted to " + accountNo),
|
||||||
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
||||||
.withDescription("Interest credited")));
|
.withDescription("Interest credited")));
|
||||||
|
// Without this the voucher exists but nothing ties it to the transaction it paid, so
|
||||||
|
// the register cannot show the two together and the transaction shows no voucher_no.
|
||||||
|
this.voucherService.linkSavingsTransaction(REF_SAVINGS_TRANSACTION, transaction.getId(), transaction.getId());
|
||||||
}
|
}
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
|
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount,
|
|
||||||
final boolean isDeposit) {
|
|
||||||
if (!this.voucherService.shouldRaiseVoucher()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
|
||||||
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
|
||||||
|
|
||||||
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
|
||||||
final String accountNo = account.getAccountNumber();
|
|
||||||
|
|
||||||
final VoucherLine memberSide = isDeposit
|
|
||||||
? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount)
|
|
||||||
: VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount);
|
|
||||||
|
|
||||||
final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount)
|
|
||||||
: VoucherLine.creditGl(null, "Cash in Hand", null, amount);
|
|
||||||
|
|
||||||
final List<VoucherLine> lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide);
|
|
||||||
|
|
||||||
return this.voucherService.createVoucherWithLines(
|
|
||||||
isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId,
|
|
||||||
currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not raise a voucher for savings transaction {}", transactionId, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+6
-2
@@ -183,6 +183,7 @@ import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
|
|||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.context.annotation.Scope;
|
import org.springframework.context.annotation.Scope;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
@@ -426,9 +427,12 @@ public class SavingsConfiguration {
|
|||||||
@ConditionalOnMissingBean(SavingsAccountReadPlatformService.class)
|
@ConditionalOnMissingBean(SavingsAccountReadPlatformService.class)
|
||||||
public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
|
public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
|
||||||
SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator,
|
SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator,
|
||||||
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator) {
|
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
|
||||||
|
// @Lazy because the accrual service depends on beans from this same package: resolving
|
||||||
|
// both eagerly closes a cycle and the context fails to start.
|
||||||
|
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
|
||||||
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
|
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
|
||||||
sqlGenerator, savingsAccountRepositoryWrapper);
|
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+14
-2
@@ -33,6 +33,7 @@ import jakarta.persistence.Transient;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
@@ -539,9 +540,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
|||||||
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
|
public ShareAccountTransaction getShareAccountTransaction(final ShareAccountTransaction transaction) {
|
||||||
ShareAccountTransaction returnTrans = null;
|
ShareAccountTransaction returnTrans = null;
|
||||||
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
|
for (ShareAccountTransaction tran : this.shareAccountTransactions) {
|
||||||
|
// A charge/fee transaction carries no share count or unit price at all (both columns are
|
||||||
|
// null), so this account's own transaction list is not uniformly non-null the way a
|
||||||
|
// purchase-only list would be -- comparing those two fields has to tolerate that instead
|
||||||
|
// of assuming every transaction here is a share purchase.
|
||||||
if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate())
|
if (DateUtils.isEqual(tran.getPurchasedDate(), transaction.getPurchasedDate())
|
||||||
&& tran.getTotalShares().equals(transaction.getTotalShares())
|
&& Objects.equals(tran.getTotalShares(), transaction.getTotalShares())
|
||||||
&& tran.getPurchasePrice().compareTo(transaction.getPurchasePrice()) == 0
|
&& nullSafeCompare(tran.getPurchasePrice(), transaction.getPurchasePrice())
|
||||||
&& tran.getTransactionStatus().equals(transaction.getTransactionStatus())
|
&& tran.getTransactionStatus().equals(transaction.getTransactionStatus())
|
||||||
&& tran.getTransactionType().equals(transaction.getTransactionType())) {
|
&& tran.getTransactionType().equals(transaction.getTransactionType())) {
|
||||||
returnTrans = tran;
|
returnTrans = tran;
|
||||||
@@ -551,6 +556,13 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
|||||||
return returnTrans;
|
return returnTrans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean nullSafeCompare(final java.math.BigDecimal a, final java.math.BigDecimal b) {
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return a == b;
|
||||||
|
}
|
||||||
|
return a.compareTo(b) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDate getSubmittedDate() {
|
public LocalDate getSubmittedDate() {
|
||||||
return this.submittedDate;
|
return this.submittedDate;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -27,6 +27,7 @@ import java.time.ZoneOffset;
|
|||||||
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.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -442,6 +443,7 @@ public class AdditionalShareTrancheFundingService {
|
|||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
final String description) {
|
final String description) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
|
||||||
this.jdbcTemplate.update(
|
this.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, entity_id, "
|
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||||
|
|||||||
+7
-15
@@ -22,6 +22,8 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
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.savings.domain.SavingsAccount;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
@@ -39,11 +41,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
|
||||||
|
|
||||||
/** Retained profit, where a dividend nobody claimed returns to. */
|
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
|
|
||||||
|
|
||||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
@@ -53,6 +50,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -130,16 +128,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
* declaration-time entries.
|
* declaration-time entries.
|
||||||
*/
|
*/
|
||||||
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
||||||
final Long payableDividendsAccountId = this.jdbcTemplate
|
final Long payableDividendsAccountId = this.dividendGlAccounts.payableDividendsAccount();
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
final Long bankProfitAccountId = this.dividendGlAccounts.retainedProfitAccount();
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long bankProfitAccountId = this.jdbcTemplate
|
|
||||||
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
@@ -156,6 +146,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
submitted_on_date, transaction_date)
|
submitted_on_date, transaction_date)
|
||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
""";
|
""";
|
||||||
|
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, payableDividendsAccountId);
|
||||||
|
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, bankProfitAccountId);
|
||||||
// Debit first, then credit, so the entry ids follow the direction the money travelled.
|
// Debit first, then credit, so the entry ids follow the direction the money travelled.
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
||||||
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
|||||||
+3
-2
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.shareaccounts.start;
|
package org.apache.fineract.portfolio.shareaccounts.start;
|
||||||
|
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
@@ -115,9 +116,9 @@ public class ShareAccountsConfiguration {
|
|||||||
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
||||||
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
||||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
||||||
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
|
PlatformSecurityContext context, JdbcTemplate jdbcTemplate, DividendGlAccountResolver dividendGlAccounts) {
|
||||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
||||||
context, jdbcTemplate);
|
context, jdbcTemplate, dividendGlAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+16
-6
@@ -39,6 +39,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.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -77,8 +78,11 @@ public class AllProductsDividendApiResource {
|
|||||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||||
private static final int DIVIDEND_STATUS_INITIATED = 100;
|
private static final int DIVIDEND_STATUS_INITIATED = 100;
|
||||||
|
|
||||||
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
|
/**
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
* The account a dividend is declared against, once year-end closing has landed the profit in it — configured as
|
||||||
|
* the Distributable Profit financial activity rather than found by a hardcoded GL code.
|
||||||
|
*/
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dividend is a rate on each member's own paid-up capital, and nothing else.
|
* A dividend is a rate on each member's own paid-up capital, and nothing else.
|
||||||
@@ -187,7 +191,7 @@ public class AllProductsDividendApiResource {
|
|||||||
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
|
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
|
||||||
out.put("ratePercentage", ratePercentage);
|
out.put("ratePercentage", ratePercentage);
|
||||||
out.put("totalWeightedCapital", totalWeightedCapital);
|
out.put("totalWeightedCapital", totalWeightedCapital);
|
||||||
out.put("profitGlCode", PROFIT_GL_CODE);
|
out.put("profitGlCode", distributableProfitGlCode());
|
||||||
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
|
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
|
||||||
// visible on screen before submission — the command layer enforces the same ceiling, and an
|
// visible on screen before submission — the command layer enforces the same ceiling, and an
|
||||||
// officer should not discover it only when the declaration is rejected.
|
// officer should not discover it only when the declaration is rejected.
|
||||||
@@ -428,9 +432,9 @@ public class AllProductsDividendApiResource {
|
|||||||
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
|
final BigDecimal closedProfit = 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(fyStart),
|
||||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
|
java.sql.Date.valueOf(fyEnd));
|
||||||
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,6 +498,12 @@ public class AllProductsDividendApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
|
/** "2026-27" for a period ending inside FY 2026-27 — April starts the year. */
|
||||||
|
/** The configured Distributable Profit account's GL code, for display on the declaration screen. */
|
||||||
|
private String distributableProfitGlCode() {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class,
|
||||||
|
dividendGlAccounts.distributableProfitAccount());
|
||||||
|
}
|
||||||
|
|
||||||
private String financialYearOf(final String isoDate) {
|
private String financialYearOf(final String isoDate) {
|
||||||
final LocalDate date = LocalDate.parse(isoDate);
|
final LocalDate date = LocalDate.parse(isoDate);
|
||||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||||
|
|||||||
+39
-96
@@ -29,6 +29,8 @@ import java.util.Set;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||||
import org.apache.fineract.commands.domain.CommandProcessingResultType;
|
import org.apache.fineract.commands.domain.CommandProcessingResultType;
|
||||||
import org.apache.fineract.commands.domain.CommandSource;
|
import org.apache.fineract.commands.domain.CommandSource;
|
||||||
@@ -88,10 +90,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
private final CommandSourceRepository commandSourceRepository;
|
private final CommandSourceRepository commandSourceRepository;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
|
||||||
// accounting module into this constructor just for two int constants.
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many financial years of dividend a member may still come and claim.
|
* How many financial years of dividend a member may still come and claim.
|
||||||
*
|
*
|
||||||
@@ -100,19 +98,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
* money is recognised as income rather than sitting as a liability nobody will ever call on.
|
* money is recognised as income rather than sitting as a liability nobody will ever call on.
|
||||||
*/
|
*/
|
||||||
private static final int CLAIMABLE_FINANCIAL_YEARS = 3;
|
private static final int CLAIMABLE_FINANCIAL_YEARS = 3;
|
||||||
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The year's closed-out profit a dividend is declared out of; kept in step with
|
* The dividend GL accounts, read from the Financial Activity Mappings a society configures.
|
||||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
*
|
||||||
|
* These were hardcoded GL codes (BANKPROFITND01 / BANKPROFIT01) until the whole dividend lifecycle was made
|
||||||
|
* configurable — see {@link DividendGlAccountResolver} for why the lookup throws on a missing mapping instead of
|
||||||
|
* posting nothing.
|
||||||
*/
|
*/
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
/**
|
|
||||||
* Where profit settles once a declaration has been approved — the undistributed remainder and the rounding
|
|
||||||
* residue. Nothing here ever enters a later dividend calculation.
|
|
||||||
*/
|
|
||||||
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
|
|
||||||
|
|
||||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
@@ -301,11 +295,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long distributableAccountId = glAccountIdByCode(PROFIT_GL_CODE);
|
final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount();
|
||||||
final Long settledAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
final Long settledAccountId = dividendGlAccounts.retainedProfitAccount();
|
||||||
if (distributableAccountId == null || settledAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String financialYear = financialYearOf(periodEndDate);
|
final String financialYear = financialYearOf(periodEndDate);
|
||||||
// Dated to the day of approval, not the period end. Closing off the remainder is an act of
|
// Dated to the day of approval, not the period end. Closing off the remainder is an act of
|
||||||
@@ -357,14 +348,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue();
|
final int belowMinimumCount = ((Number) allocation.get("below_minimum_count")).intValue();
|
||||||
final BigDecimal residue = declaredAmount.subtract(allocatedAmount);
|
final BigDecimal residue = declaredAmount.subtract(allocatedAmount);
|
||||||
|
|
||||||
// Swept to the settled account, not back to the pool it came from: the residue belongs to the
|
// Resolved here so a missing mapping is reported while the declaration can still be abandoned, rather than
|
||||||
// year that was just declared, and returning it to the distributable balance would put it
|
// first surfacing at approval. Never back to the distributable pool it came from: the residue belongs to the
|
||||||
// straight back into what the next declaration measures.
|
// year just declared, and returning it there would put it straight into what the next declaration measures.
|
||||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
dividendGlAccounts.dividendRoundOffAccount();
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
dividendGlAccounts.payableDividendsAccount();
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
|
|
||||||
// A residue of zero still gets its row: "nothing was left over" is itself the answer to the
|
// A residue of zero still gets its row: "nothing was left over" is itself the answer to the
|
||||||
// audit question, and its absence would be indistinguishable from the sweep never running.
|
// audit question, and its absence would be indistinguishable from the sweep never running.
|
||||||
@@ -411,26 +399,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
// The round-off account when the society has mapped one, retained profit otherwise — see
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
// DividendGlAccountResolver#dividendRoundOffAccount for why this one resolves rather than throwing.
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
final Long roundOffAccountId = dividendGlAccounts.dividendRoundOffAccount();
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
// Left unswept rather than forced through: swept_to_profit_date stays null so the residue is
|
|
||||||
// still identifiable and can be swept once the GL setup is completed.
|
|
||||||
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
|
final LocalDate periodEndDate = ((java.sql.Date) row.get("period_end_date")).toLocalDate();
|
||||||
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
final java.sql.Date sweptDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||||
final String transactionId = "DIVROUND" + dividendPayOutId;
|
final String transactionId = "DIVROUND" + dividendPayOutId;
|
||||||
|
|
||||||
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
|
// Approval credited the whole declared amount to Dividend Payable; this takes back the slice
|
||||||
// that will never be paid to a member and returns it to retained profit.
|
// that will never be paid to a member, because no member's share divided into it evenly.
|
||||||
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, residue, (String) row.get("currency_code"), transactionId,
|
postJournalEntryPair(payableDividendsAccountId, roundOffAccountId, residue, (String) row.get("currency_code"), transactionId,
|
||||||
sweptDate,
|
sweptDate,
|
||||||
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept to profit — declaration #" + dividendPayOutId);
|
"FY " + financialYearOf(periodEndDate) + " dividend rounding residue swept — declaration #" + dividendPayOutId);
|
||||||
|
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_share_dividend_rounding_residue
|
UPDATE m_share_dividend_rounding_residue
|
||||||
@@ -511,9 +493,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
final BigDecimal closedProfit = jdbcTemplate.queryForObject("""
|
final BigDecimal closedProfit = 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(fyStart),
|
||||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(fyStart), java.sql.Date.valueOf(fyEnd));
|
java.sql.Date.valueOf(fyEnd));
|
||||||
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
return closedProfit == null ? BigDecimal.ZERO : closedProfit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,11 +525,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Long glAccountIdByCode(final String glCode) {
|
|
||||||
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declaring a dividend charges it against the society's retained profit and creates the obligation to members.
|
* Declaring a dividend charges it against the society's retained profit and creates the obligation to members.
|
||||||
*
|
*
|
||||||
@@ -564,24 +541,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
* year-end closing, so the round trip reconciles.
|
* year-end closing, so the round trip reconciles.
|
||||||
*
|
*
|
||||||
* Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which
|
* Posted as plain journal-entry pairs rather than through the transaction-bound AccountingProcessorHelper, which
|
||||||
* expects a loan/savings/share *transaction* rather than a standalone declaration event. Falls back to posting
|
* expects a loan/savings/share *transaction* rather than a standalone declaration event.
|
||||||
* Bank Profit straight to Dividend Payable if the Reserve is not mapped, so declaring is never blocked by
|
*
|
||||||
* incomplete GL setup.
|
* Both accounts must be mapped; there is no longer a degraded path that posts straight to Dividend Payable when
|
||||||
|
* Reserve is absent. Skipping the appropriation would put a liability to members on the books at declaration, which
|
||||||
|
* is exactly what routing through Reserve exists to prevent — so an unmapped Reserve is a setup error to be
|
||||||
|
* reported, not a variation to be absorbed.
|
||||||
*/
|
*/
|
||||||
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount,
|
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount,
|
||||||
final String financialYear) {
|
final String financialYear) {
|
||||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE);
|
final Long bankProfitAccountId = dividendGlAccounts.distributableProfitAccount();
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long reserveAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (bankProfitAccountId == null || payableDividendsAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||||
@@ -591,12 +561,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
// across years, and without the year on the line the balances only ever give a running total.
|
// across years, and without the year on the line the balances only ever give a running total.
|
||||||
final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId;
|
final String description = "FY " + financialYear + " dividend declared out of profit — declaration #" + dividendPayOutId;
|
||||||
|
|
||||||
if (reserveAccountId == null) {
|
|
||||||
postJournalEntryPair(bankProfitAccountId, payableDividendsAccountId, amount, currencyCode, transactionId, sqlEntryDate,
|
|
||||||
description);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the appropriation posts here. The release to members is a separate act that waits for
|
// Only the appropriation posts here. The release to members is a separate act that waits for
|
||||||
// approval — see releaseReserveToDividendPayable. Posting both at declaration would put a
|
// approval — see releaseReserveToDividendPayable. Posting both at declaration would put a
|
||||||
// liability to members on the books before anyone had approved paying them.
|
// liability to members on the books before anyone had approved paying them.
|
||||||
@@ -619,20 +583,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
* Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside.
|
* Reserve nets to nil once both legs post; its credit column is the cumulative profit ever set aside.
|
||||||
*/
|
*/
|
||||||
private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) {
|
private void releaseReserveToDividendPayable(final ShareProductDividendPayOutDetails declaration) {
|
||||||
final Long reserveAccountId = jdbcTemplate
|
final Long reserveAccountId = dividendGlAccounts.reserveForDividendAccount();
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||||
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
|
|
||||||
// With no Reserve mapped, the declaration already posted Bank Profit straight to Dividend
|
|
||||||
// Payable, so the liability is on the books and there is nothing left to release.
|
|
||||||
if (reserveAccountId == null || payableDividendsAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read back from the row rather than the entity: the amount and product are not exposed as
|
// Read back from the row rather than the entity: the amount and product are not exposed as
|
||||||
// getters, and this matches how closeOffUndistributedProfit reads the same declaration.
|
// getters, and this matches how closeOffUndistributedProfit reads the same declaration.
|
||||||
@@ -660,6 +612,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
|
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
|
||||||
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) {
|
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description) {
|
||||||
|
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, debitAccountId);
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, creditAccountId);
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
jdbcTemplate.update(JOURNAL_ENTRY_INSERT_SQL, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
||||||
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
|
amount, description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
|
||||||
@@ -745,11 +699,8 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS);
|
final Long payableDividendsAccountId = dividendGlAccounts.payableDividendsAccount();
|
||||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
final Long bankProfitAccountId = dividendGlAccounts.retainedProfitAccount();
|
||||||
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||||
for (final Map<String, Object> lock : expired) {
|
for (final Map<String, Object> lock : expired) {
|
||||||
@@ -769,14 +720,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A GL account mapped to a financial activity, or null when the society has not configured it. */
|
|
||||||
private Long glAccountIdByFinancialActivity(final int financialActivityType) {
|
|
||||||
return jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
financialActivityType)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void makeDividendsClaimable(final Long payOutDetailId) {
|
private void makeDividendsClaimable(final Long payOutDetailId) {
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
||||||
|
|||||||
+5
-2
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.shareproducts.starter;
|
package org.apache.fineract.portfolio.shareproducts.starter;
|
||||||
|
|
||||||
import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService;
|
import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
|
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingReadPlatformService;
|
||||||
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingWritePlatformService;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
@@ -117,11 +118,13 @@ public class ShareProductsConfiguration {
|
|||||||
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||||
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
|
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate,
|
||||||
|
DividendGlAccountResolver dividendGlAccounts) {
|
||||||
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
||||||
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
||||||
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
||||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
|
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate,
|
||||||
|
dividendGlAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -52,11 +52,13 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReprintRequestWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDRevokeRequestWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDWritePlatformService;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -72,6 +74,7 @@ public class SponsorDDApiResource {
|
|||||||
private final SponsorDDReconciliationService reconciliationService;
|
private final SponsorDDReconciliationService reconciliationService;
|
||||||
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
||||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||||
|
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
||||||
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
@@ -426,6 +429,49 @@ public class SponsorDDApiResource {
|
|||||||
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Revoking a leaflet spoiled at the printer ──────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("leaflets/{leafletId}/revoke-request")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Request to revoke a spoiled DD and replace it",
|
||||||
|
description = "Clerk (maker) step: the leaflet was ruined at the printer, so its draft must move onto the next available blank. Distinct from a reprint, which re-prints the same leaflet.")
|
||||||
|
public CommandProcessingResult requestRevoke(@PathParam("leafletId") final Long leafletId, final ReprintRequestRequest request) {
|
||||||
|
return safely(() -> this.revokeRequestWritePlatformService.requestRevoke(leafletId, request.getReason()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("revoke-requests")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve DD revoke requests",
|
||||||
|
description = "Returns all DD revoke requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue.")
|
||||||
|
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(@QueryParam("status") final String status) {
|
||||||
|
return this.readPlatformService.retrieveAllRevokeRequests(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("revoke-requests/{revokeRequestId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve a DD revoke request",
|
||||||
|
description = "Manager/head-cashier (checker) step: marks the spoiled leaflet REVOKED and moves its draft onto the next available blank. No money moves - the customer was already debited and is still owed the same draft.")
|
||||||
|
public CommandProcessingResult approveRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId) {
|
||||||
|
return safely(() -> this.revokeRequestWritePlatformService.approveRevokeRequest(revokeRequestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("revoke-requests/{revokeRequestId}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject a DD revoke request",
|
||||||
|
description = "Manager/head-cashier (checker) step: rejects a pending DD revoke request. The leaflet stays issued.")
|
||||||
|
public CommandProcessingResult rejectRevokeRequest(@PathParam("revokeRequestId") final Long revokeRequestId,
|
||||||
|
final RejectIssueRequestRequest request) {
|
||||||
|
return safely(() -> this.revokeRequestWritePlatformService.rejectRevokeRequest(revokeRequestId, request.getRejectionReason()));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
|
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
+16
@@ -59,4 +59,20 @@ public class SponsorDDLeafletData implements Serializable {
|
|||||||
private String approverUsername;
|
private String approverUsername;
|
||||||
/** Set while a correction to this draft is awaiting a manager. */
|
/** Set while a correction to this draft is awaiting a manager. */
|
||||||
private Long pendingAmendmentRequestId;
|
private Long pendingAmendmentRequestId;
|
||||||
|
|
||||||
|
/** Set while a revoke is awaiting a checker, so the register offers it only once. */
|
||||||
|
private Long pendingRevokeRequestId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a spoiled leaflet's draft went, and which spoiled leaflet a replacement stands in for.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Both directions are shown in the register: a revoked leaflet reads as consumed stock with a
|
||||||
|
* pointer to the draft that survived it, and the replacement says what it was issued against.
|
||||||
|
* Without the pair the pad simply comes up short against the sponsor bank's range.
|
||||||
|
*/
|
||||||
|
private Long revokedReplacedByLeafletId;
|
||||||
|
private String revokedReplacedByDdNumber;
|
||||||
|
private Long revokedReplacementOfLeafletId;
|
||||||
|
private String revokedReplacementOfDdNumber;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sponsordd.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request to replace a leaflet spoiled at the printer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Carries the draft's own details as well as the request's, because a checker approving this is
|
||||||
|
* deciding to consume a blank from limited stock - they need to see whose draft it is and for how
|
||||||
|
* much without opening the register in another tab.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SponsorDDRevokeRequestData implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long leafletId;
|
||||||
|
private String ddNumber;
|
||||||
|
private String reason;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** Null until approved: no blank is consumed while the request is still pending. */
|
||||||
|
private Long replacementLeafletId;
|
||||||
|
private String replacementDdNumber;
|
||||||
|
|
||||||
|
/** The draft being moved, so the approval queue can be read on its own. */
|
||||||
|
private String beneficiaryName;
|
||||||
|
private String clientName;
|
||||||
|
private BigDecimal faceValue;
|
||||||
|
private String sponsorBankName;
|
||||||
|
|
||||||
|
private Long makerId;
|
||||||
|
private String makerUsername;
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
private Long checkerId;
|
||||||
|
private String checkerUsername;
|
||||||
|
private LocalDateTime checkerDate;
|
||||||
|
private String rejectionReason;
|
||||||
|
}
|
||||||
+4
@@ -26,6 +26,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||||
|
|
||||||
public interface SponsorDDReadPlatformService {
|
public interface SponsorDDReadPlatformService {
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ public interface SponsorDDReadPlatformService {
|
|||||||
|
|
||||||
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
||||||
|
|
||||||
|
/** Requests to replace a leaflet spoiled at the printer, for the manager approval queue. */
|
||||||
|
Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(String status);
|
||||||
|
|
||||||
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
|
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
|
||||||
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
|
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
|
||||||
|
|
||||||
|
|||||||
+92
-6
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDRevokeRequestData;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -75,6 +76,18 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series order for a book of drafts.
|
||||||
|
*
|
||||||
|
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 — which handed the
|
||||||
|
* clerk draft 10 immediately after draft 1 and showed the register out of order. Pre-printed
|
||||||
|
* stationery has to be consumed in series, so a wholly numeric series is cast and ordered
|
||||||
|
* numerically; a series carrying a prefix keeps text order, which is already its own sequence.
|
||||||
|
*/
|
||||||
|
private static final String LEAFLET_SERIES_ORDER =
|
||||||
|
"order by case when leaf.dd_number ~ '^[0-9]+$' then cast(leaf.dd_number as bigint) end asc nulls last, "
|
||||||
|
+ "leaf.dd_number asc";
|
||||||
|
|
||||||
private static final String LEAFLET_SELECT =
|
private static final String LEAFLET_SELECT =
|
||||||
"select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, "
|
"select leaf.id, leaf.inventory_id, leaf.dd_number, leaf.status, leaf.face_value, leaf.issued_to_client_id, "
|
||||||
+ "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, "
|
+ "c.display_name as client_name, leaf.walkin_purchaser_name, leaf.walkin_purchaser_address, "
|
||||||
@@ -83,7 +96,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
||||||
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
||||||
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
|
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
|
||||||
+ "amr.id as pending_amendment_request_id "
|
+ "amr.id as pending_amendment_request_id, rvr.id as pending_revoke_request_id, "
|
||||||
|
+ "leaf.revoked_replaced_by_leaflet_id, rby.dd_number as revoked_replaced_by_dd_number, "
|
||||||
|
+ "leaf.revoked_replacement_of_leaflet_id, rof.dd_number as revoked_replacement_of_dd_number "
|
||||||
+ "from m_sponsor_dd_leaflet leaf "
|
+ "from m_sponsor_dd_leaflet leaf "
|
||||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||||
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
||||||
@@ -94,11 +109,20 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
+ "left join m_appuser approver on approver.id = apr.checker_id "
|
+ "left join m_appuser approver on approver.id = apr.checker_id "
|
||||||
// Lets the registry show that a correction is already awaiting a manager, instead of
|
// Lets the registry show that a correction is already awaiting a manager, instead of
|
||||||
// offering Edit again and letting a second request be raised over the same draft.
|
// offering Edit again and letting a second request be raised over the same draft.
|
||||||
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' ";
|
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' "
|
||||||
|
// Same reason as the amendment join: the register must not offer Revoke twice over one
|
||||||
|
// spoiled leaflet while a checker still has the first request.
|
||||||
|
+ "left join m_sponsor_dd_revoke_request rvr on rvr.leaflet_id = leaf.id and rvr.status = 'PENDING' "
|
||||||
|
// Both ends of a replacement, so the register can say where a revoked draft went and
|
||||||
|
// what a replacement was issued against.
|
||||||
|
+ "left join m_sponsor_dd_leaflet rby on rby.id = leaf.revoked_replaced_by_leaflet_id "
|
||||||
|
+ "left join m_sponsor_dd_leaflet rof on rof.id = leaf.revoked_replacement_of_leaflet_id ";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
||||||
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? order by leaf.dd_number asc";
|
// Numeric-aware, for the same reason as retrieveNextAvailableLeaflet: a text sort shows the
|
||||||
|
// register as 1, 10, 2, 3 and the book reads as though it were issued out of order.
|
||||||
|
final String sql = LEAFLET_SELECT + "where leaf.inventory_id = ? " + LEAFLET_SERIES_ORDER;
|
||||||
return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId);
|
return this.jdbcTemplate.query(sql, this.leafletMapper, inventoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +194,22 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
final String approverUsername = rs.getString("approver_username");
|
final String approverUsername = rs.getString("approver_username");
|
||||||
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
|
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
|
||||||
? rs.getLong("pending_amendment_request_id") : null;
|
? rs.getLong("pending_amendment_request_id") : null;
|
||||||
|
final Long pendingRevokeRequestId = rs.getObject("pending_revoke_request_id") != null
|
||||||
|
? rs.getLong("pending_revoke_request_id")
|
||||||
|
: null;
|
||||||
|
final Long revokedReplacedByLeafletId = rs.getObject("revoked_replaced_by_leaflet_id") != null
|
||||||
|
? rs.getLong("revoked_replaced_by_leaflet_id")
|
||||||
|
: null;
|
||||||
|
final Long revokedReplacementOfLeafletId = rs.getObject("revoked_replacement_of_leaflet_id") != null
|
||||||
|
? rs.getLong("revoked_replacement_of_leaflet_id")
|
||||||
|
: null;
|
||||||
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
||||||
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
||||||
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
||||||
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
||||||
approverUserId, approverUsername, pendingAmendmentRequestId);
|
approverUserId, approverUsername, pendingAmendmentRequestId, pendingRevokeRequestId,
|
||||||
|
revokedReplacedByLeafletId, rs.getString("revoked_replaced_by_dd_number"), revokedReplacementOfLeafletId,
|
||||||
|
rs.getString("revoked_replacement_of_dd_number"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +449,54 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
return this.jdbcTemplate.query(sql, new ReprintRequestMapper());
|
return this.jdbcTemplate.query(sql, new ReprintRequestMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request, the draft it is about, and the blank that replaced it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Joined out to the leaflet and the client because approving this consumes a blank from limited
|
||||||
|
* stock: the checker is deciding about a specific customer's draft, not an abstract request id.
|
||||||
|
*/
|
||||||
|
private static final String REVOKE_REQUEST_SELECT =
|
||||||
|
"select rv.id, rv.leaflet_id, leaf.dd_number, rv.reason, rv.status, rv.replacement_leaflet_id, "
|
||||||
|
+ "repl.dd_number as replacement_dd_number, leaf.beneficiary_name, "
|
||||||
|
+ "coalesce(c.display_name, leaf.walkin_purchaser_name) as client_name, leaf.face_value, "
|
||||||
|
+ "sb.name as sponsor_bank_name, rv.maker_id, maker.username as maker_username, rv.created_date, "
|
||||||
|
+ "rv.checker_id, checker.username as checker_username, rv.checker_date, rv.rejection_reason "
|
||||||
|
+ "from m_sponsor_dd_revoke_request rv "
|
||||||
|
+ "join m_sponsor_dd_leaflet leaf on leaf.id = rv.leaflet_id "
|
||||||
|
+ "left join m_sponsor_dd_leaflet repl on repl.id = rv.replacement_leaflet_id "
|
||||||
|
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||||
|
+ "left join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id "
|
||||||
|
+ "left join m_sponsor_bank sb on sb.id = inv.sponsor_bank_id "
|
||||||
|
+ "join m_appuser maker on maker.id = rv.maker_id "
|
||||||
|
+ "left join m_appuser checker on checker.id = rv.checker_id ";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<SponsorDDRevokeRequestData> retrieveAllRevokeRequests(final String status) {
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "where rv.status = ? order by rv.id desc",
|
||||||
|
new RevokeRequestMapper(), status);
|
||||||
|
}
|
||||||
|
return this.jdbcTemplate.query(REVOKE_REQUEST_SELECT + "order by rv.id desc", new RevokeRequestMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RevokeRequestMapper implements RowMapper<SponsorDDRevokeRequestData> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SponsorDDRevokeRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
return new SponsorDDRevokeRequestData(rs.getLong("id"), rs.getLong("leaflet_id"), rs.getString("dd_number"),
|
||||||
|
rs.getString("reason"), rs.getString("status"),
|
||||||
|
rs.getObject("replacement_leaflet_id") != null ? rs.getLong("replacement_leaflet_id") : null,
|
||||||
|
rs.getString("replacement_dd_number"), rs.getString("beneficiary_name"), rs.getString("client_name"),
|
||||||
|
rs.getBigDecimal("face_value"), rs.getString("sponsor_bank_name"), rs.getLong("maker_id"),
|
||||||
|
rs.getString("maker_username"),
|
||||||
|
rs.getTimestamp("created_date") != null ? rs.getTimestamp("created_date").toLocalDateTime() : null,
|
||||||
|
rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null, rs.getString("checker_username"),
|
||||||
|
rs.getTimestamp("checker_date") != null ? rs.getTimestamp("checker_date").toLocalDateTime() : null,
|
||||||
|
rs.getString("rejection_reason"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> {
|
private static final class ReprintRequestMapper implements RowMapper<SponsorDDReprintRequestData> {
|
||||||
@Override
|
@Override
|
||||||
public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
public SponsorDDReprintRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
@@ -502,7 +585,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
sql += " AND leaf.advice_status = ? ";
|
sql += " AND leaf.advice_status = ? ";
|
||||||
params.add(adviceStatus);
|
params.add(adviceStatus);
|
||||||
}
|
}
|
||||||
sql += " ORDER BY leaf.dd_number ASC";
|
sql += " " + LEAFLET_SERIES_ORDER;
|
||||||
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +609,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
sql.append("and inv.sponsor_bank_id = ? ");
|
sql.append("and inv.sponsor_bank_id = ? ");
|
||||||
params.add(sponsorBankId);
|
params.add(sponsorBankId);
|
||||||
}
|
}
|
||||||
sql.append("order by inv.id asc, leaf.dd_number asc limit 1");
|
// Oldest book first, then strictly up its own series — see LEAFLET_SERIES_ORDER.
|
||||||
|
sql.append("order by inv.id asc, ");
|
||||||
|
sql.append(LEAFLET_SERIES_ORDER.substring("order by ".length()));
|
||||||
|
sql.append(" limit 1");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
|
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
|
||||||
|
|||||||
+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.sponsordd.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacing a draft whose leaflet was spoiled at the printer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Distinct from a reprint, which puts the same draft through the printer again on the same leaflet.
|
||||||
|
* Here the paper itself is ruined: its printed number can never be handed to anyone, so the draft
|
||||||
|
* has to move onto the next blank in the pad. Both leaflets are then accounted for - one spoiled,
|
||||||
|
* one carrying the draft - which is what lets the branch's stock reconcile against the pad the
|
||||||
|
* sponsor bank sent.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* No money moves. The customer was debited when the draft was issued and is still owed exactly one
|
||||||
|
* draft for exactly that amount; only the instrument changes.
|
||||||
|
*/
|
||||||
|
public interface SponsorDDRevokeRequestWritePlatformService {
|
||||||
|
|
||||||
|
/** Raised by the branch that spoiled the leaflet. Nothing moves until a checker approves. */
|
||||||
|
CommandProcessingResult requestRevoke(Long leafletId, String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the spoiled leaflet REVOKED and moves its draft onto the next available blank.
|
||||||
|
*
|
||||||
|
* @return the request id, with the replacement leaflet as the sub-entity
|
||||||
|
*/
|
||||||
|
CommandProcessingResult approveRevokeRequest(Long revokeRequestId);
|
||||||
|
|
||||||
|
CommandProcessingResult rejectRevokeRequest(Long revokeRequestId, String rejectionReason);
|
||||||
|
}
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sponsordd.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SponsorDDRevokeRequestWritePlatformServiceImpl implements SponsorDDRevokeRequestWritePlatformService {
|
||||||
|
|
||||||
|
private static final String STATUS_PENDING = "PENDING";
|
||||||
|
private static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
private static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
private static final String LEAFLET_AVAILABLE = "AVAILABLE";
|
||||||
|
private static final String LEAFLET_ISSUED = "ISSUED";
|
||||||
|
private static final String LEAFLET_REVOKED = "REVOKED";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series order for pre-printed stationery.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@code dd_number} is a varchar, so ordering it as text runs 1, 10, 2, 3 - which handed out
|
||||||
|
* draft 10 as the replacement for draft 1 while drafts 2 to 9 sat unused, and left the pad
|
||||||
|
* consumed out of sequence with no way to reconcile it against the sponsor bank's range. A
|
||||||
|
* wholly numeric series is cast and ordered numerically; a series carrying a prefix keeps text
|
||||||
|
* order, which is already its own sequence.
|
||||||
|
*/
|
||||||
|
private static final String SERIES_ORDER =
|
||||||
|
"case when l.dd_number ~ '^[0-9]+$' then cast(l.dd_number as bigint) end asc nulls last, l.dd_number asc";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult requestRevoke(final Long leafletId, final String reason) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
|
||||||
|
|
||||||
|
if (!LEAFLET_ISSUED.equals(leaflet.get("status"))) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Only an issued Demand Draft can be revoked. Current status: " + leaflet.get("status"));
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("A reason is required to revoke a spoiled Demand Draft.");
|
||||||
|
}
|
||||||
|
if (!this.jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_sponsor_dd_revoke_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
|
||||||
|
.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"A revoke request is already pending for this Demand Draft. Wait for it to be approved or rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked at request time as well as at approval: a clerk who has run out of blank drafts
|
||||||
|
// needs to know now, while the customer is still at the counter, rather than after a
|
||||||
|
// checker has been asked to approve something that cannot be carried out.
|
||||||
|
if (findReplacementLeaflet(leafletId) == null) {
|
||||||
|
throw new IllegalStateException("No blank Demand Draft is available to replace this one - "
|
||||||
|
+ "not in this series, elsewhere in this sponsor bank's stock, or with any other sponsor bank. "
|
||||||
|
+ "Add fresh DD inventory before revoking.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(con -> {
|
||||||
|
final java.sql.PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO m_sponsor_dd_revoke_request (leaflet_id, reason, status, maker_id, created_date) "
|
||||||
|
+ "VALUES (?, ?, ?, ?, ?)",
|
||||||
|
new String[] { "id" });
|
||||||
|
ps.setLong(1, leafletId);
|
||||||
|
ps.setString(2, reason);
|
||||||
|
ps.setString(3, STATUS_PENDING);
|
||||||
|
ps.setLong(4, currentUser.getId());
|
||||||
|
ps.setObject(5, now);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult approveRevokeRequest(final Long revokeRequestId) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot approve your own revoke request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long spoiledId = ((Number) request.get("leaflet_id")).longValue();
|
||||||
|
final Map<String, Object> spoiled = findLeafletOrThrow(spoiledId);
|
||||||
|
if (!LEAFLET_ISSUED.equals(spoiled.get("status"))) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"This Demand Draft is no longer issued. Current status: " + spoiled.get("status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-checked here rather than trusted from request time: stock moves, and the blank that was
|
||||||
|
// free when the clerk asked may have been issued to someone else in the meantime.
|
||||||
|
final Map<String, Object> replacement = findReplacementLeaflet(spoiledId);
|
||||||
|
if (replacement == null) {
|
||||||
|
throw new IllegalStateException("No blank Demand Draft is available to replace this one. "
|
||||||
|
+ "Add fresh DD inventory, then approve this request.");
|
||||||
|
}
|
||||||
|
final Long replacementId = ((Number) replacement.get("id")).longValue();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
|
||||||
|
// The draft moves wholesale onto the new leaflet - same customer, same beneficiary, same
|
||||||
|
// amount. Nothing is recalculated: the customer was debited once, for this draft, and is
|
||||||
|
// owed exactly this draft on whatever paper the branch can actually print.
|
||||||
|
this.jdbcTemplate.update("UPDATE m_sponsor_dd_leaflet SET status = ?, face_value = ?, issued_to_client_id = ?, "
|
||||||
|
+ "beneficiary_name = ?, issuing_branch_id = ?, issued_date = ?, walkin_purchaser_name = ?, "
|
||||||
|
+ "walkin_purchaser_address = ?, walkin_purchaser_aadhaar_masked = ?, "
|
||||||
|
+ "revoked_replacement_of_leaflet_id = ?, lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||||
|
LEAFLET_ISSUED, spoiled.get("face_value"), spoiled.get("issued_to_client_id"),
|
||||||
|
spoiled.get("beneficiary_name"), spoiled.get("issuing_branch_id"), spoiled.get("issued_date"),
|
||||||
|
spoiled.get("walkin_purchaser_name"), spoiled.get("walkin_purchaser_address"),
|
||||||
|
spoiled.get("walkin_purchaser_aadhaar_masked"), spoiledId, currentUser.getId(), now, replacementId);
|
||||||
|
|
||||||
|
// The spoiled leaflet keeps its beneficiary and amount. It is a record of a draft that was
|
||||||
|
// printed and destroyed, not an empty slot, and blanking it would leave the pad short by one
|
||||||
|
// with nothing to say where it went.
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_sponsor_dd_leaflet SET status = ?, revoked_replaced_by_leaflet_id = ?, "
|
||||||
|
+ "lastmodifiedby_id = ?, lastmodified_date = ? WHERE id = ?",
|
||||||
|
LEAFLET_REVOKED, replacementId, currentUser.getId(), now, spoiledId);
|
||||||
|
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_sponsor_dd_revoke_request SET status = ?, replacement_leaflet_id = ?, checker_id = ?, "
|
||||||
|
+ "checker_date = ? WHERE id = ?",
|
||||||
|
STATUS_APPROVED, replacementId, currentUser.getId(), now, revokeRequestId);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).withSubEntityId(replacementId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult rejectRevokeRequest(final Long revokeRequestId, final String rejectionReason) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findRevokeRequestOrThrow(revokeRequestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Revoke request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot reject your own revoke request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_sponsor_dd_revoke_request SET status = ?, checker_id = ?, checker_date = ?, "
|
||||||
|
+ "rejection_reason = ? WHERE id = ?",
|
||||||
|
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, revokeRequestId);
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(revokeRequestId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blank that should carry the draft instead, searched in the order a branch would reach for
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Nearest first: the next number in the same pad, so the series stays unbroken and the sponsor
|
||||||
|
* bank's stock reconciles against one continuous range. Then anywhere else in the same sponsor
|
||||||
|
* bank's stock - a second pad from the same bank is still that bank's paper and its drafts are
|
||||||
|
* honoured the same way. Only then another sponsor bank, which is a real fallback rather than a
|
||||||
|
* preference: the draft is drawn on a different bank, so the branch would rather not, but a
|
||||||
|
* customer standing at the counter needs a draft more than the branch needs a tidy series.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Null when the branch has no blank draft anywhere, which is a stock problem and is reported as
|
||||||
|
* one rather than silently issuing nothing.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> findReplacementLeaflet(final Long spoiledLeafletId) {
|
||||||
|
// The next number after the spoiled one, so the pad is consumed in the order it was
|
||||||
|
// printed. Falls back to the lowest free number in the pad when the spoiled draft was the
|
||||||
|
// last one, rather than skipping the series entirely.
|
||||||
|
final List<Map<String, Object>> afterSpoiled = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||||
|
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||||
|
+ "AND l.status = ? AND l.id <> ? "
|
||||||
|
+ "AND l.dd_number ~ '^[0-9]+$' "
|
||||||
|
+ "AND cast(l.dd_number as bigint) > "
|
||||||
|
+ " (SELECT case when s.dd_number ~ '^[0-9]+$' then cast(s.dd_number as bigint) else -1 end "
|
||||||
|
+ " FROM m_sponsor_dd_leaflet s WHERE s.id = ?) "
|
||||||
|
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||||
|
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId, spoiledLeafletId);
|
||||||
|
if (!afterSpoiled.isEmpty()) {
|
||||||
|
return afterSpoiled.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> sameInventory = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||||
|
+ "WHERE l.inventory_id = (SELECT inventory_id FROM m_sponsor_dd_leaflet WHERE id = ?) "
|
||||||
|
+ "AND l.status = ? AND l.id <> ? "
|
||||||
|
+ "ORDER BY " + SERIES_ORDER + " LIMIT 1",
|
||||||
|
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||||
|
if (!sameInventory.isEmpty()) {
|
||||||
|
return sameInventory.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> sameBank = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||||
|
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||||
|
+ "WHERE i.sponsor_bank_id = (SELECT i2.sponsor_bank_id FROM m_sponsor_dd_inventory i2 "
|
||||||
|
+ " JOIN m_sponsor_dd_leaflet l2 ON l2.inventory_id = i2.id WHERE l2.id = ?) "
|
||||||
|
+ "AND l.status = ? AND l.id <> ? "
|
||||||
|
+ "ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||||
|
spoiledLeafletId, LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||||
|
if (!sameBank.isEmpty()) {
|
||||||
|
return sameBank.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> anyBank = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT l.id FROM m_sponsor_dd_leaflet l "
|
||||||
|
+ "JOIN m_sponsor_dd_inventory i ON i.id = l.inventory_id "
|
||||||
|
+ "WHERE l.status = ? AND l.id <> ? ORDER BY i.id, " + SERIES_ORDER + " LIMIT 1",
|
||||||
|
LEAFLET_AVAILABLE, spoiledLeafletId);
|
||||||
|
return anyBank.isEmpty() ? null : anyBank.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Demand Draft leaflet not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> findRevokeRequestOrThrow(final Long revokeRequestId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM m_sponsor_dd_revoke_request WHERE id = ?", revokeRequestId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Sponsor DD revoke request not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCheckerRole(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCheckerRole(final AppUser user) {
|
||||||
|
if (!isCheckerRole(user)) {
|
||||||
|
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject revoke requests.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.tds.api;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||||
|
import org.apache.fineract.portfolio.tds.service.TdsInterestReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/tds/deposit-interest")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "TDS on deposit interest",
|
||||||
|
description = "Interest credited on deposits in a financial year, per customer, for the section 194A deduction.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TdsInterestApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final TdsInterestReadPlatformService readPlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<TdsDeducteeData> toApiJsonSerializer;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||||
|
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||||
|
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||||
|
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||||
|
return this.toApiJsonSerializer.serialize(deductees);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The financial year the books are currently in, off the business date rather than the wall
|
||||||
|
* clock, so a back-dated working day reports the year that day belongs to.
|
||||||
|
*/
|
||||||
|
private int currentFinancialYear() {
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
return businessDate.getMonthValue() >= 4 ? businessDate.getYear() : businessDate.getYear() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.tds.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||||
|
* together with the accounts and the interest actually credited to them.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||||
|
* their own {@code depositTypeCode} - the caller keeps them out of the aggregate and shows them
|
||||||
|
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||||
|
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||||
|
* customer disputes a deduction.
|
||||||
|
*/
|
||||||
|
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||||
|
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One deposit account and the interest posted to it.
|
||||||
|
*
|
||||||
|
* @param depositTypeCode
|
||||||
|
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||||
|
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||||
|
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||||
|
* a flag this report worked out for itself.
|
||||||
|
*/
|
||||||
|
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||||
|
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||||
|
|
||||||
|
/** One interest posting: the date it was credited and the amount. */
|
||||||
|
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||||
|
implements Serializable {}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.tds.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||||
|
|
||||||
|
public interface TdsInterestReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deposit interest credited in a financial year, per customer.
|
||||||
|
*
|
||||||
|
* @param financialYear
|
||||||
|
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||||
|
* 31 Mar 2027
|
||||||
|
*/
|
||||||
|
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.tds.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||||
|
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||||
|
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||||
|
* rather than from a balance and a rate.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||||
|
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||||
|
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||||
|
* transaction type is filtered here rather than being inferred later from an amount.
|
||||||
|
*
|
||||||
|
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||||
|
*/
|
||||||
|
private static final int INTEREST_POSTING = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||||
|
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||||
|
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||||
|
* deduct tax on interest the customer never received.
|
||||||
|
*/
|
||||||
|
private static final String SQL = """
|
||||||
|
SELECT c.id AS client_id,
|
||||||
|
c.account_no AS customer_id,
|
||||||
|
c.display_name AS name,
|
||||||
|
c.date_of_birth AS date_of_birth,
|
||||||
|
k.pan_number AS pan,
|
||||||
|
k.form15_type AS form15_type,
|
||||||
|
o.name AS branch_name,
|
||||||
|
sa.id AS account_id,
|
||||||
|
sa.account_no AS account_no,
|
||||||
|
sa.activatedon_date AS opened_on,
|
||||||
|
CASE sa.deposit_type_enum
|
||||||
|
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||||
|
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||||
|
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||||
|
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||||
|
t.transaction_date AS credit_date,
|
||||||
|
t.amount AS credit_amount
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||||
|
LEFT JOIN m_savings_account_transaction t
|
||||||
|
ON t.savings_account_id = sa.id
|
||||||
|
AND t.transaction_type_enum = ?
|
||||||
|
AND t.is_reversed = false
|
||||||
|
AND t.transaction_date >= ?
|
||||||
|
AND t.transaction_date <= ?
|
||||||
|
WHERE sa.client_id IS NOT NULL
|
||||||
|
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||||
|
// The Indian financial year, 1 April to 31 March.
|
||||||
|
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||||
|
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||||
|
|
||||||
|
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
jdbcTemplate.query(SQL, rs -> {
|
||||||
|
final long clientId = rs.getLong("client_id");
|
||||||
|
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||||
|
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||||
|
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||||
|
readString(rs, "form15_type")));
|
||||||
|
|
||||||
|
final long accountId = rs.getLong("account_id");
|
||||||
|
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||||
|
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||||
|
|
||||||
|
// Null where the outer join found no posting inside the year: the account is still
|
||||||
|
// listed, it simply earned nothing in the window.
|
||||||
|
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||||
|
if (creditDate != null) {
|
||||||
|
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||||
|
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||||
|
}
|
||||||
|
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||||
|
|
||||||
|
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||||
|
for (final Deductee d : byClient.values()) {
|
||||||
|
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||||
|
for (final Account a : d.accounts.values()) {
|
||||||
|
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||||
|
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||||
|
// savings account, so it is left null and the caller falls back to the account
|
||||||
|
// holder rather than inventing a joint holder who may not exist.
|
||||||
|
accounts.add(
|
||||||
|
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||||
|
}
|
||||||
|
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||||
|
d.form15Type, accounts));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readString(final ResultSet rs, final String column) {
|
||||||
|
try {
|
||||||
|
return rs.getString(column);
|
||||||
|
} catch (final SQLException e) {
|
||||||
|
throw new IllegalStateException("Could not read column " + column, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||||
|
try {
|
||||||
|
final java.sql.Date value = rs.getDate(column);
|
||||||
|
return value == null ? null : value.toLocalDate();
|
||||||
|
} catch (final SQLException e) {
|
||||||
|
throw new IllegalStateException("Could not read column " + column, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable accumulators, private to the grouping above. */
|
||||||
|
private static final class Deductee {
|
||||||
|
|
||||||
|
private final Long clientId;
|
||||||
|
private final String customerId;
|
||||||
|
private final String name;
|
||||||
|
private final String pan;
|
||||||
|
private final LocalDate dateOfBirth;
|
||||||
|
private final String branchName;
|
||||||
|
private final String form15Type;
|
||||||
|
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||||
|
String branchName, String form15Type) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.customerId = customerId;
|
||||||
|
this.name = name;
|
||||||
|
this.pan = pan;
|
||||||
|
this.dateOfBirth = dateOfBirth;
|
||||||
|
this.branchName = branchName;
|
||||||
|
this.form15Type = form15Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Account {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String accountNo;
|
||||||
|
private final String depositTypeCode;
|
||||||
|
private final LocalDate openedOn;
|
||||||
|
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||||
|
|
||||||
|
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||||
|
this.id = id;
|
||||||
|
this.accountNo = accountNo;
|
||||||
|
this.depositTypeCode = depositTypeCode;
|
||||||
|
this.openedOn = openedOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+292
-1
@@ -435,7 +435,14 @@ public class VoucherVerificationApiResource {
|
|||||||
@Path("report")
|
@Path("report")
|
||||||
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
|
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
|
||||||
@QueryParam("cashOnly") final Boolean cashOnly) {
|
@QueryParam("cashOnly") final Boolean cashOnly,
|
||||||
|
@QueryParam("cashiersOnly") final Boolean cashiersOnlyParam,
|
||||||
|
@QueryParam("systemOnly") final Boolean systemOnlyParam) {
|
||||||
|
|
||||||
|
// Defaults to false so an existing caller that does not send the flag keeps seeing the whole
|
||||||
|
// register; the two counter screens opt in.
|
||||||
|
final boolean cashiersOnly = Boolean.TRUE.equals(cashiersOnlyParam);
|
||||||
|
final boolean systemOnly = Boolean.TRUE.equals(systemOnlyParam);
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
final boolean seesEveryone = isCheckerRole(currentUser);
|
final boolean seesEveryone = isCheckerRole(currentUser);
|
||||||
@@ -457,6 +464,15 @@ public class VoucherVerificationApiResource {
|
|||||||
|
|
||||||
final StringBuilder sql = new StringBuilder("""
|
final StringBuilder sql = new StringBuilder("""
|
||||||
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
||||||
|
-- The date the posting actually belongs to, which is not always the date the
|
||||||
|
-- voucher was written. A voucher's created_at is a wall-clock instant, while
|
||||||
|
-- the transaction behind it carries the business date — so a nightly run that
|
||||||
|
-- finishes after midnight stamps its vouchers with the next day although the
|
||||||
|
-- ledger sits on the day it worked on. Somebody reading this register today
|
||||||
|
-- is looking at last night's work, and needs to be told which day it is for.
|
||||||
|
COALESCE(t.transaction_date, CAST(v.created_at AS DATE)) AS ledger_date,
|
||||||
|
(COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
|
||||||
|
< CAST(v.created_at AS DATE)) AS posted_after_close,
|
||||||
v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
|
v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
|
||||||
ver.status AS verification_status,
|
ver.status AS verification_status,
|
||||||
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
||||||
@@ -465,6 +481,17 @@ public class VoucherVerificationApiResource {
|
|||||||
cash.moved AS is_cash
|
cash.moved AS is_cash
|
||||||
FROM m_voucher v
|
FROM m_voucher v
|
||||||
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
|
-- Carries the business date the posting belongs to, for ledger_date above.
|
||||||
|
--
|
||||||
|
-- Matched on either link. savings_txn_id is stamped by a second step after the
|
||||||
|
-- voucher is written, and on the interest register it is empty on every row --
|
||||||
|
-- reference_id already holds the same transaction id, put there when the voucher
|
||||||
|
-- was raised. Joining only on savings_txn_id left the ledger date blank on
|
||||||
|
-- precisely the postings this column exists for.
|
||||||
|
LEFT JOIN m_savings_account_transaction t
|
||||||
|
ON t.id = COALESCE(v.savings_txn_id,
|
||||||
|
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
|
||||||
|
THEN v.reference_id END)
|
||||||
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT party_name, account_no FROM m_voucher_line
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
@@ -537,6 +564,38 @@ public class VoucherVerificationApiResource {
|
|||||||
sql.append(" AND v.voucher_type = ?");
|
sql.append(" AND v.voucher_type = ?");
|
||||||
args.add(voucherType.trim());
|
args.add(voucherType.trim());
|
||||||
}
|
}
|
||||||
|
// Narrowed to the people who hold a drawer.
|
||||||
|
//
|
||||||
|
// These two screens are the counter's own books: what came in over it and what went out.
|
||||||
|
// Everyone else's work — a clerk raising a request, a manager approving one, interest a
|
||||||
|
// nightly run posted — is their own to account for, and each person reads it on My Vouchers.
|
||||||
|
// Mixed in here it buried the cashier rows these registers exist to reconcile, and left the
|
||||||
|
// totals disagreeing with any drawer.
|
||||||
|
//
|
||||||
|
// Read from m_cashiers rather than from a role name, because holding a drawer is what makes
|
||||||
|
// somebody a cashier — a role called "Cashier" can be granted to anybody, and somebody
|
||||||
|
// covering a drawer without that role would otherwise vanish from the register they are
|
||||||
|
// answerable for. A username is matched through the staff record the drawer belongs to.
|
||||||
|
if (cashiersOnly) {
|
||||||
|
sql.append(" AND v.created_by_username IN (SELECT u.username FROM m_cashiers c"
|
||||||
|
+ " JOIN m_appuser u ON u.staff_id = c.staff_id WHERE u.username IS NOT NULL)");
|
||||||
|
}
|
||||||
|
// Postings nobody filed a request for: interest a nightly run credited, a charge collected
|
||||||
|
// from a balance, an escheat, a journal keyed straight to the ledger.
|
||||||
|
//
|
||||||
|
// Identified by what they are not — none of the five registers a person raises through. A
|
||||||
|
// reference type is stamped at post time and cannot be granted or revoked the way a role
|
||||||
|
// can, so this holds even when a job runs as somebody's own login rather than as `system`.
|
||||||
|
//
|
||||||
|
// These have no home on any other screen: My Vouchers is scoped to the reader's own work
|
||||||
|
// and a nightly job is nobody's, and the two counter registers deliberately exclude
|
||||||
|
// everything that did not cross a drawer. Without this filter the only way to see what the
|
||||||
|
// system posted was to read the whole register and discount the rest by eye.
|
||||||
|
if (systemOnly) {
|
||||||
|
sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN ("
|
||||||
|
+ "'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST', 'CLERK_CLOSURE_REQUEST',"
|
||||||
|
+ " 'CLERK_DISBURSEMENT_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST'))");
|
||||||
|
}
|
||||||
// A cashier's register is cash: what came over the counter in notes and coins.
|
// A cashier's register is cash: what came over the counter in notes and coins.
|
||||||
//
|
//
|
||||||
// The test is whether a side of the voucher is the till (the `till` join above). That is
|
// The test is whether a side of the voucher is the till (the `till` join above). That is
|
||||||
@@ -580,6 +639,11 @@ public class VoucherVerificationApiResource {
|
|||||||
m.put("voucherType", r.get("voucher_type"));
|
m.put("voucherType", r.get("voucher_type"));
|
||||||
m.put("amount", amount);
|
m.put("amount", amount);
|
||||||
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||||
|
// The day the posting belongs to, and whether that differs from the day it was written.
|
||||||
|
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
|
||||||
|
// work: the register has to say so, or the reader takes the timestamp for the date.
|
||||||
|
m.put("ledgerDate", r.get("ledger_date") == null ? null : r.get("ledger_date").toString());
|
||||||
|
m.put("postedAfterClose", Boolean.TRUE.equals(r.get("posted_after_close")));
|
||||||
m.put("userId", r.get("created_by"));
|
m.put("userId", r.get("created_by"));
|
||||||
m.put("username", r.get("created_by_username"));
|
m.put("username", r.get("created_by_username"));
|
||||||
m.put("branchName", r.get("branch_name"));
|
m.put("branchName", r.get("branch_name"));
|
||||||
@@ -607,12 +671,239 @@ public class VoucherVerificationApiResource {
|
|||||||
// from the caller's roles a second time.
|
// from the caller's roles a second time.
|
||||||
res.put("canSeeAllUsers", seesEveryone);
|
res.put("canSeeAllUsers", seesEveryone);
|
||||||
res.put("cashOnly", restrictToCash);
|
res.put("cashOnly", restrictToCash);
|
||||||
|
// So the screen can say whose register it is showing rather than leaving the reader to
|
||||||
|
// wonder why their own entries are absent from it.
|
||||||
|
res.put("cashiersOnly", cashiersOnly);
|
||||||
res.put("vouchers", out);
|
res.put("vouchers", out);
|
||||||
res.put("count", out.size());
|
res.put("count", out.size());
|
||||||
res.put("totalAmount", total);
|
res.put("totalAmount", total);
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user's day: everything they raised and everything they verified, receipts and payments
|
||||||
|
* together.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The Received and Payment registers each answer "what came in / went out today", across
|
||||||
|
* everyone. This answers the other question a branch asks at a close — "what did I do today" —
|
||||||
|
* and it cannot be assembled from those two, for two reasons. A checker's work leaves no
|
||||||
|
* voucher of their own: the slip stays in the maker's name, and the only trace of the approval
|
||||||
|
* is the verification row, which the registers do not read. And a person's day is one list, not
|
||||||
|
* a receipt list plus a payment list they have to reconcile in their head.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each row therefore says which side it is (CR or DR, from the voucher's own series) and what
|
||||||
|
* the user's part in it was:
|
||||||
|
* <ul>
|
||||||
|
* <li>RAISED — they entered it;
|
||||||
|
* <li>VERIFIED — somebody else entered it, they checked it;
|
||||||
|
* <li>RAISED_AND_VERIFIED — both, which a small branch does legitimately (a manager posting and
|
||||||
|
* verifying their own entry), and which an auditor wants to see marked rather than hidden.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Always scoped to the caller, with no username parameter — a checker looking at another
|
||||||
|
* person's day uses the register with its user filter. This screen is "my work", and a
|
||||||
|
* parameter that let it be somebody else's would make its own heading a lie.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("my-work")
|
||||||
|
public String myWork(@QueryParam("from") final String from, @QueryParam("to") final String to) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final String username = currentUser.getUsername();
|
||||||
|
|
||||||
|
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final LocalDate fromDate = parseReportDate(from, today);
|
||||||
|
final LocalDate toDate = parseReportDate(to, today);
|
||||||
|
if (toDate.isBefore(fromDate)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid",
|
||||||
|
"The end of the period cannot fall before its start.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voucher qualifies on any of three counts, so the filter is an OR across them rather
|
||||||
|
// than three queries stitched together — that way a voucher the user both raised and
|
||||||
|
// actioned appears once, marked as both, instead of twice.
|
||||||
|
//
|
||||||
|
// "Approved" has two distinct meanings in this system and both belong here:
|
||||||
|
//
|
||||||
|
// - the maker-checker decision, held as manager_id/checker_id on the register's own row.
|
||||||
|
// This is the one an approver actually performs and the one they came looking for; it
|
||||||
|
// raises no voucher of its own, because the slip stays in the maker's name.
|
||||||
|
// - the physical cash check, held in m_voucher_verification, where somebody counted the
|
||||||
|
// notes against the slip.
|
||||||
|
//
|
||||||
|
// Reading only the second was the bug: a manager who approved a withdrawal all morning saw
|
||||||
|
// an empty page, because approving is not counting. The registers are unioned into one
|
||||||
|
// lookup keyed by voucher number, so each register's own approver column is read where it
|
||||||
|
// actually lives instead of being duplicated onto the voucher.
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
WITH approvals AS (
|
||||||
|
SELECT voucher_no, manager_id AS approver_id, manager_name AS approver_name, actioned_at
|
||||||
|
FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||||
|
FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||||
|
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, manager_id, manager_name, actioned_at
|
||||||
|
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
|
||||||
|
),
|
||||||
|
-- Who submitted the request the voucher belongs to.
|
||||||
|
--
|
||||||
|
-- Needed because m_voucher.created_by does not stay with the maker: when a cashier
|
||||||
|
-- hands the cash over, updateCashier() reassigns the voucher to them so it reaches
|
||||||
|
-- the cashier's own register. That reassignment overwrites rather than adds, so the
|
||||||
|
-- clerk who raised the request disappeared from their own page the moment it was
|
||||||
|
-- paid out — the register's maker column is the only surviving record of them.
|
||||||
|
makers AS (
|
||||||
|
SELECT voucher_no, clerk_id AS maker_id, status AS request_status
|
||||||
|
FROM m_clerk_withdrawal_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
||||||
|
v.created_by_username, o.name AS branch_name,
|
||||||
|
-- Where the voucher stands. The register's own status is what a person wants:
|
||||||
|
-- PENDING, APPROVED, WITHDRAWN, REJECTED — the life of the request they
|
||||||
|
-- raised or passed. The verification status is a different question (has
|
||||||
|
-- anybody counted the notes against the slip), answered on most days by
|
||||||
|
-- nobody, so showing only that left the column blank on every row.
|
||||||
|
COALESCE(mk.request_status, ver.status) AS verification_status,
|
||||||
|
-- Whoever actioned it, whichever way it was actioned. The maker-checker
|
||||||
|
-- decision is named first: it is the one a person is answerable for.
|
||||||
|
COALESCE(ap.approver_name, ver.verified_by_name) AS verified_by_name,
|
||||||
|
COALESCE(ap.actioned_at, ver.verified_at) AS verified_at,
|
||||||
|
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
||||||
|
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
||||||
|
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
|
||||||
|
(v.created_by_username = ? OR mk.maker_id = ?) AS raised_by_me,
|
||||||
|
(ver.verified_by = ? OR ap.approver_id = ?) AS verified_by_me,
|
||||||
|
-- Whether either side of the voucher is the till itself — same test as the
|
||||||
|
-- register listing below, and for the same reason: a Collection/Disbursement
|
||||||
|
-- request is numbered in the CR/DR series (it debits or credits a member's
|
||||||
|
-- own account), but when its other leg is a GL account rather than the vault,
|
||||||
|
-- no cash actually crossed the counter. Counting it under Received or Payment
|
||||||
|
-- read as double-counting the exact figure already shown on the Journal-free
|
||||||
|
-- day totals, when it was really a book entry between two ledgers.
|
||||||
|
till.touched AS till_touched
|
||||||
|
FROM m_voucher v
|
||||||
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
|
LEFT JOIN approvals ap ON ap.voucher_no = v.voucher_no
|
||||||
|
LEFT JOIN makers mk ON mk.voucher_no = v.voucher_no
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
|
||||||
|
ORDER BY line_no LIMIT 1) dr ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||||
|
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRUE AS touched FROM m_voucher_line cl
|
||||||
|
WHERE cl.voucher_no = v.voucher_no
|
||||||
|
AND (cl.gl_account_id IN (
|
||||||
|
SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa
|
||||||
|
WHERE fa.financial_activity_type IN (101, 102))
|
||||||
|
OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand'))
|
||||||
|
LIMIT 1) till ON TRUE
|
||||||
|
WHERE v.created_at >= ? AND v.created_at < ?
|
||||||
|
AND (v.created_by_username = ? OR mk.maker_id = ? OR ver.verified_by = ? OR ap.approver_id = ?)
|
||||||
|
ORDER BY v.created_at DESC, v.voucher_no DESC
|
||||||
|
""", username, currentUser.getId(), currentUser.getId(), currentUser.getId(),
|
||||||
|
Timestamp.valueOf(fromDate.atStartOfDay()), Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()),
|
||||||
|
username, currentUser.getId(), currentUser.getId(), currentUser.getId());
|
||||||
|
|
||||||
|
BigDecimal receivedTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal paymentTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal journalTotal = BigDecimal.ZERO;
|
||||||
|
int raisedCount = 0;
|
||||||
|
int verifiedCount = 0;
|
||||||
|
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
for (final Map<String, Object> r : rows) {
|
||||||
|
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||||
|
final String voucherNo = (String) r.get("voucher_no");
|
||||||
|
final int lineCount = ((Number) r.get("line_count")).intValue();
|
||||||
|
// The series the number was issued in is the side: a register's own prefix is the one
|
||||||
|
// thing that cannot disagree with the voucher, whereas re-deriving CR/DR from the type
|
||||||
|
// would have to keep a list of types in step with the ones that issue them.
|
||||||
|
final String rawSide = voucherNo == null ? null
|
||||||
|
: voucherNo.startsWith("CR") ? "CR" : voucherNo.startsWith("DR") ? "DR" : "JV";
|
||||||
|
// A CR/DR voucher whose lines never touch the till (the same test bookBreakdown() uses
|
||||||
|
// below) is a book entry between two ledgers that happened to be numbered in the cash
|
||||||
|
// series — a Collection/Disbursement request posted against a GL account rather than the
|
||||||
|
// vault, e.g. a savings account debited straight into a Sundry GL account. Reading its
|
||||||
|
// prefix at face value counted that figure a second time, on top of whatever Journal
|
||||||
|
// already showed, as if real cash had crossed the counter. A voucher with no lines yet
|
||||||
|
// (raised but not actioned) is left alone — there is nothing to test the till against,
|
||||||
|
// and it is still real expected cash, not yet a book entry either way.
|
||||||
|
final boolean tillTouched = Boolean.TRUE.equals(r.get("till_touched"));
|
||||||
|
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
|
||||||
|
? "JV" : rawSide;
|
||||||
|
|
||||||
|
if (amount != null) {
|
||||||
|
if ("CR".equals(side)) {
|
||||||
|
receivedTotal = receivedTotal.add(amount);
|
||||||
|
} else if ("DR".equals(side)) {
|
||||||
|
paymentTotal = paymentTotal.add(amount);
|
||||||
|
} else {
|
||||||
|
journalTotal = journalTotal.add(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean raisedByMe = Boolean.TRUE.equals(r.get("raised_by_me"));
|
||||||
|
final boolean verifiedByMe = Boolean.TRUE.equals(r.get("verified_by_me"));
|
||||||
|
if (raisedByMe) {
|
||||||
|
raisedCount++;
|
||||||
|
}
|
||||||
|
if (verifiedByMe) {
|
||||||
|
verifiedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("voucherNo", voucherNo);
|
||||||
|
m.put("side", side);
|
||||||
|
m.put("voucherType", r.get("voucher_type"));
|
||||||
|
m.put("amount", amount);
|
||||||
|
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||||
|
m.put("raisedBy", r.get("created_by_username"));
|
||||||
|
m.put("branchName", r.get("branch_name"));
|
||||||
|
m.put("verificationStatus", r.get("verification_status"));
|
||||||
|
m.put("verifiedByName", r.get("verified_by_name"));
|
||||||
|
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
|
||||||
|
m.put("debitParty", r.get("debit_party"));
|
||||||
|
m.put("debitAccount", r.get("debit_account"));
|
||||||
|
m.put("creditParty", r.get("credit_party"));
|
||||||
|
m.put("creditAccount", r.get("credit_account"));
|
||||||
|
m.put("extraLines", Math.max(0, lineCount - 2));
|
||||||
|
m.put("myRole", raisedByMe && verifiedByMe ? "RAISED_AND_VERIFIED" : raisedByMe ? "RAISED" : "VERIFIED");
|
||||||
|
out.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> res = new LinkedHashMap<>();
|
||||||
|
res.put("from", fromDate.toString());
|
||||||
|
res.put("to", toDate.toString());
|
||||||
|
res.put("username", username);
|
||||||
|
res.put("displayName", currentUser.getDisplayName());
|
||||||
|
res.put("vouchers", out);
|
||||||
|
res.put("count", out.size());
|
||||||
|
res.put("raisedCount", raisedCount);
|
||||||
|
res.put("verifiedCount", verifiedCount);
|
||||||
|
res.put("receivedTotal", receivedTotal);
|
||||||
|
res.put("paymentTotal", paymentTotal);
|
||||||
|
res.put("journalTotal", journalTotal);
|
||||||
|
return gson.toJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
/** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */
|
/** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */
|
||||||
private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
|
private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
|
|||||||
+129
@@ -24,11 +24,14 @@ import java.sql.Timestamp;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
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.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ import org.springframework.stereotype.Service;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class VoucherService {
|
public class VoucherService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(VoucherService.class);
|
||||||
|
|
||||||
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
||||||
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
||||||
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
||||||
@@ -66,6 +71,8 @@ public class VoucherService {
|
|||||||
public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
|
public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST";
|
||||||
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
||||||
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
||||||
|
/** A share redemption approved through the clerk register, keyed by the request id. */
|
||||||
|
public static final String REF_CLERK_SHARE_WITHDRAWAL_REQUEST = "CLERK_SHARE_WITHDRAWAL_REQUEST";
|
||||||
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
|
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
|
||||||
|
|
||||||
/** A voucher raised directly as a journal, rather than by one of the cash registers. */
|
/** A voucher raised directly as a journal, rather than by one of the cash registers. */
|
||||||
@@ -87,12 +94,20 @@ public class VoucherService {
|
|||||||
*/
|
*/
|
||||||
public static final String PREFIX_JOURNAL = "JV";
|
public static final String PREFIX_JOURNAL = "JV";
|
||||||
|
|
||||||
|
/** A charge or fee collected from an account. */
|
||||||
|
public static final String TYPE_CHARGE = "CHARGE";
|
||||||
|
/** An unclaimed balance moved to the escheat liability. */
|
||||||
|
public static final String TYPE_ESCHEAT = "ESCHEAT";
|
||||||
|
|
||||||
/** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
|
/** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
|
||||||
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
|
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
|
||||||
|
|
||||||
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
|
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
|
||||||
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY";
|
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY";
|
||||||
|
|
||||||
|
/** A savings movement, keyed by its m_savings_account_transaction id. */
|
||||||
|
public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
|
||||||
|
|
||||||
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
|
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -254,6 +269,120 @@ public class VoucherService {
|
|||||||
return voucherNo;
|
return voucherNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises one voucher for one savings transaction, reading its sides from the journal entries the
|
||||||
|
* posting has already written.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This is how a branch register actually works: the slip is the transaction, not the leg. A
|
||||||
|
* deposit that also collects a charge posts three or four GL legs and gets ONE credit voucher
|
||||||
|
* carrying all of them, because that is what the member signed and what the teller files. Every
|
||||||
|
* register that builds its lines by hand risks drifting from the ledger it is supposed to
|
||||||
|
* mirror; this cannot, because it reads the ledger.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The series follows the transaction's own direction, the way a bank's voucher books are kept:
|
||||||
|
* <ul>
|
||||||
|
* <li>money in over the counter — a deposit — is a <b>credit voucher</b> (CR);
|
||||||
|
* <li>money out — a withdrawal, a charge collected in cash — is a <b>debit voucher</b> (DR);
|
||||||
|
* <li>anything that moves between accounts without cash crossing the counter — interest posted,
|
||||||
|
* a charge taken from the balance, a transfer, an escheat — is a <b>journal voucher</b> (JV).
|
||||||
|
* </ul>
|
||||||
|
* The caller passes the type and prefix because only the caller knows whether cash was involved:
|
||||||
|
* the same charge is a DR voucher at the counter and a JV when a batch job collects it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Returns null, and raises nothing, when the transaction posted no journal entries — an on-hold
|
||||||
|
* or a release moves no money and has no voucher in a real branch either. A failure is logged
|
||||||
|
* rather than thrown: the transaction and its GL entries have already committed, and losing the
|
||||||
|
* slip is worth less than rolling a member's money back.
|
||||||
|
*
|
||||||
|
* @param savingsTxnId
|
||||||
|
* the m_savings_account_transaction id; its journal entries become the voucher lines
|
||||||
|
* @return the voucher number, or null if none was raised
|
||||||
|
*/
|
||||||
|
public String createVoucherFromJournalEntries(final String voucherType, final String referenceType, final Long savingsTxnId,
|
||||||
|
final AppUser user, final String prefix) {
|
||||||
|
|
||||||
|
if (savingsTxnId == null || !shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Reversed legs are excluded: a correction posts its own reversing pair, and including
|
||||||
|
// both would net the voucher to zero and fail the balance check.
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT je.type_enum, je.amount, je.account_id, gl.name AS gl_name, gl.gl_code,
|
||||||
|
je.description
|
||||||
|
FROM acc_gl_journal_entry je
|
||||||
|
JOIN acc_gl_account gl ON gl.id = je.account_id
|
||||||
|
WHERE je.savings_transaction_id = ?
|
||||||
|
AND (je.reversed IS NULL OR je.reversed = false)
|
||||||
|
ORDER BY je.type_enum DESC, je.id
|
||||||
|
""", savingsTxnId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> party = savingsPartyForTransaction(savingsTxnId);
|
||||||
|
final String partyName = (String) party.get("party_name");
|
||||||
|
final String accountNo = (String) party.get("account_no");
|
||||||
|
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Long glId = row.get("account_id") == null ? null : ((Number) row.get("account_id")).longValue();
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||||
|
// The member's own side is named after the member; every other leg is named after
|
||||||
|
// its GL account, which is what a voucher shows for an income or expense head.
|
||||||
|
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
|
||||||
|
final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
|
||||||
|
final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
|
||||||
|
|
||||||
|
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
|
||||||
|
final int typeEnum = ((Number) row.get("type_enum")).intValue();
|
||||||
|
VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
|
||||||
|
: VoucherLine.creditGl(glId, name, ref, amount);
|
||||||
|
final String description = (String) row.get("description");
|
||||||
|
lines.add(description == null ? line : line.withDescription(description));
|
||||||
|
}
|
||||||
|
|
||||||
|
final String voucherNo = createVoucherWithLines(voucherType, referenceType, savingsTxnId, user, prefix, lines);
|
||||||
|
linkSavingsTransaction(referenceType, savingsTxnId, savingsTxnId);
|
||||||
|
return voucherNo;
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not raise a voucher for savings transaction {}", savingsTxnId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voucher already raised against a savings transaction, if any. */
|
||||||
|
public String findVoucherNoForSavingsTransaction(final Long savingsTxnId) {
|
||||||
|
if (savingsTxnId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return jdbcTemplate
|
||||||
|
.queryForList("SELECT voucher_no FROM m_voucher WHERE savings_txn_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||||
|
savingsTxnId)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The member and account a savings transaction belongs to, plus the product's control GL. */
|
||||||
|
private Map<String, Object> savingsPartyForTransaction(final Long savingsTxnId) {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
|
||||||
|
sa.account_no,
|
||||||
|
apm.gl_account_id AS control_gl_id
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
LEFT JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||||
|
AND apm.product_type = 2 AND apm.financial_account_type = 2
|
||||||
|
WHERE t.id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", savingsTxnId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks that the lines form a voucher, and returns the value of one side.
|
* Checks that the lines form a voucher, and returns the value of one side.
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user