Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af43944699 | ||
|
|
39fa100c70 | ||
|
|
1c6ac9d77f | ||
|
|
6bb512e1fe | ||
|
|
04ac233fbe | ||
|
|
9e0c04583d | ||
|
|
c20a7d0728 | ||
|
|
3d196276c8 | ||
|
|
5cc5d680f2 | ||
|
|
6aa655a8a3 | ||
|
|
518b4d4afc | ||
|
|
05bceea2d4 | ||
|
|
e33e0f23fe | ||
|
|
317a308d8c | ||
|
|
a83806062e | ||
|
|
e4cc2e558e | ||
|
|
a04b65583e | ||
|
|
f6686b0c8f | ||
|
|
45c2826a42 | ||
|
|
c86e0d1cc2 | ||
|
|
c052beeacf | ||
|
|
942bad6c8c | ||
|
|
5a9a103832 | ||
|
|
fd91da3d76 | ||
|
|
c4a99fea5d | ||
|
|
dc0539d53d | ||
|
|
509370eaeb | ||
|
|
1130a482a0 | ||
|
|
8ecab08d57 | ||
|
|
29677e610c | ||
|
|
6ffb0dd9c0 | ||
|
|
8d224a22b4 | ||
|
|
f746a1fb2c | ||
|
|
1bc9d0901d | ||
|
|
08519da8b1 | ||
|
|
58187c23d0 | ||
|
|
179fe3a6ce | ||
|
|
72221f704d | ||
|
|
a42a874811 | ||
|
|
cad7f54c47 | ||
|
|
1a2a945464 | ||
|
|
73ec85cfd6 | ||
|
|
46f5a1babd | ||
|
|
20fe11e60d | ||
|
|
eb1aa767f7 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
1a9adaec24 | ||
|
|
2da5a25e04 | ||
|
|
baf30cff68 | ||
|
|
f1d68d17fe | ||
|
|
d7d22382a5 | ||
|
|
7cdc25322e | ||
|
|
a4c3dbb6c4 | ||
|
|
dd8445de06 | ||
|
|
9345f2294c | ||
|
|
190cd92a85 |
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
||||
}
|
||||
|
||||
ext['groovy.version'] = '4.0.17'
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
|
||||
|
||||
// Apply static weaving configuration to all subprojects
|
||||
subprojects { subproject ->
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Removes the duplicate vouchers left behind by the double-count bug.
|
||||
--
|
||||
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
|
||||
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
|
||||
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
|
||||
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
|
||||
-- and Payment reports counted both, doubling the day's cash.
|
||||
--
|
||||
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
|
||||
--
|
||||
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
|
||||
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
|
||||
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
|
||||
-- that link and orphan the request row.
|
||||
--
|
||||
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
|
||||
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
|
||||
-- genuinely separate transactions of the same value on the same day are never confused.
|
||||
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
|
||||
SELECT sav.voucher_no
|
||||
FROM m_voucher sav
|
||||
JOIN m_voucher reg
|
||||
ON reg.savings_txn_id = sav.reference_id
|
||||
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
|
||||
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
|
||||
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
|
||||
|
||||
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
|
||||
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
|
||||
-- rather than removed silently.
|
||||
DO $$
|
||||
DECLARE
|
||||
blocked INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO blocked
|
||||
FROM (SELECT voucher_no FROM m_voucher_verification
|
||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
|
||||
UNION ALL
|
||||
SELECT voucher_no FROM m_voucher_attachment
|
||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
|
||||
IF blocked > 0 THEN
|
||||
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
|
||||
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
|
||||
DO $$
|
||||
DECLARE
|
||||
stamped INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
|
||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||
IF stamped > 0 THEN
|
||||
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
|
||||
|
||||
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||
|
||||
COMMIT;
|
||||
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
|
||||
|
||||
INSERT IGNORE INTO m_charge
|
||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
||||
is_free_withdrawal, is_payment_type, income_or_liability_account_id)
|
||||
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
|
||||
|
||||
INSERT IGNORE INTO m_charge
|
||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
||||
@@ -168,6 +160,5 @@ VALUES
|
||||
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
|
||||
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
|
||||
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
||||
|
||||
@@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
|
||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||
|
||||
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
|
||||
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||
|
||||
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
|
||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||
|
||||
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
|
||||
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||
|
||||
+20
-9
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
|
||||
*/
|
||||
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 paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||
|
||||
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
|
||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||
element);
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||
|
||||
+5
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
||||
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
||||
GL_ACCOUNT_DISABLED, //
|
||||
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
||||
GL_ACCOUNT_IS_HEADER, //
|
||||
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
||||
|
||||
public String errorMessage() {
|
||||
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
||||
return "Invalid debit or credit accounts are passed";
|
||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||
return "Target account does not allow manual adjustments";
|
||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
|
||||
}
|
||||
return name();
|
||||
}
|
||||
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
||||
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||
return "error.msg.glJournalEntry.invalid.account.is.header";
|
||||
}
|
||||
return name();
|
||||
}
|
||||
|
||||
+19
-6
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
||||
.notNull().integerGreaterThanZero();
|
||||
|
||||
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
|
||||
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
||||
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
||||
}
|
||||
|
||||
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
|
||||
// only, so they are validated when supplied rather than demanded from every savings product.
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
||||
element)) {
|
||||
final Long overdraftAccount = this.fromApiJsonHelper
|
||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
|
||||
final Long incomeFromInterest = this.fromApiJsonHelper
|
||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
|
||||
.notNull().integerGreaterThanZero();
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
||||
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
||||
}
|
||||
|
||||
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
||||
element);
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
||||
.integerGreaterThanZero();
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
||||
final Long writtenOff = this.fromApiJsonHelper
|
||||
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
||||
.notNull().integerGreaterThanZero();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
@@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||
// Expense
|
||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||
@@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||
// Expense
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||
|
||||
+18
@@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
||||
PortfolioProductType.SAVING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create-or-merge, for an expense row a product may not have yet.
|
||||
*
|
||||
* The plain merge variant assumes the mapping already exists, which is true of every expense
|
||||
* row that shipped with the product screen. A row added later — the rounding adjustment — is
|
||||
* absent on every product created before it, so editing one of those has to create the
|
||||
* mapping rather than fail looking for it.
|
||||
*/
|
||||
public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName,
|
||||
final Long productId, final int accountTypeId, final Map<String, Object> changes) {
|
||||
createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE,
|
||||
PortfolioProductType.SAVING);
|
||||
}
|
||||
|
||||
/*** Abstractions for payments channel related to savings products ***/
|
||||
|
||||
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
||||
@@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||
break;
|
||||
case ACCRUAL_PERIODIC:
|
||||
// asset
|
||||
@@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
||||
|
||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||
|
||||
break;
|
||||
case ACCRUAL_UPFRONT:
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
||||
// Configure Java compilation
|
||||
tasks.named('compileJava') {
|
||||
outputs.cacheIf { true }
|
||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+45
-5
@@ -269,7 +269,15 @@ public final class AccountingConstants {
|
||||
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
||||
INCOME_FROM_INTEREST(12), //
|
||||
LOSSES_WRITTEN_OFF(13), //
|
||||
ESCHEAT_LIABILITY(14); //
|
||||
ESCHEAT_LIABILITY(14), //
|
||||
/**
|
||||
* Where the paise dropped from a cash closure are absorbed.
|
||||
*
|
||||
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
|
||||
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
|
||||
* well clear of the accrual slots so the two sets never collide as either grows.
|
||||
*/
|
||||
ROUNDING_ADJUSTMENT(26); //
|
||||
|
||||
private final Integer value;
|
||||
|
||||
@@ -317,7 +325,9 @@ public final class AccountingConstants {
|
||||
FEES_RECEIVABLE(15), //
|
||||
PENALTIES_RECEIVABLE(16), //
|
||||
INTEREST_PAYABLE(17), //
|
||||
INTEREST_RECEIVABLE(18);
|
||||
INTEREST_RECEIVABLE(18), //
|
||||
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
|
||||
ROUNDING_ADJUSTMENT(26);
|
||||
|
||||
private final Integer value;
|
||||
|
||||
@@ -372,7 +382,8 @@ public final class AccountingConstants {
|
||||
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
||||
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
||||
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
||||
INTEREST_PAYABLE("interestPayableAccountId");
|
||||
INTEREST_PAYABLE("interestPayableAccountId"), //
|
||||
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -410,7 +421,8 @@ public final class AccountingConstants {
|
||||
FEES_RECEIVABLE("feeReceivableAccount"), //
|
||||
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
||||
INTEREST_PAYABLE("interestPayableAccount"), //
|
||||
INTEREST_RECEIVABLE("interestReceivableAccount"); //
|
||||
INTEREST_RECEIVABLE("interestReceivableAccount"), //
|
||||
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -436,7 +448,35 @@ public final class AccountingConstants {
|
||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||
/**
|
||||
* The year's closed-out profit a dividend is declared against.
|
||||
*
|
||||
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||
* distribution a second time.
|
||||
*/
|
||||
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||
* calculation.
|
||||
*/
|
||||
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||
/**
|
||||
* Where the paise a dividend cannot divide evenly are collected.
|
||||
*
|
||||
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||
*
|
||||
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||
*/
|
||||
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+20
-1
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -151,6 +151,25 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+33
@@ -664,6 +664,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "OFFICE";
|
||||
this.entityId = officeId;
|
||||
this.href = "/offices/" + officeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createOfficeTransaction() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "OFFICETRANSACTION";
|
||||
@@ -2211,6 +2219,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "CBSBASEINTERESTRATE";
|
||||
this.entityId = null;
|
||||
this.href = "/fixeddepositproducts/baseinterestrates";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||
@@ -2897,6 +2913,15 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
|
||||
this.actionName = "PAYMENT";
|
||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||
this.entityId = accountId;
|
||||
this.savingsId = accountId;
|
||||
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||
this.actionName = "CALCULATEINTEREST";
|
||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||
@@ -3103,6 +3128,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FACILITYCHARGEMAPPING";
|
||||
this.entityId = mappingId;
|
||||
this.href = "/facilitychargemappings/" + mappingId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
|
||||
|
||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||
|
||||
boolean isMakerCheckerEnabled();
|
||||
|
||||
/**
|
||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||
|
||||
+13
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
|
||||
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
|
||||
* args passed to it).
|
||||
*/
|
||||
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
|
||||
final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
|
||||
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
|
||||
this.value, defaultUserMessageArgs);
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
||||
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
||||
|
||||
+5
@@ -38,7 +38,11 @@ public enum JobName {
|
||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||
@@ -52,6 +56,7 @@ public enum JobName {
|
||||
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
||||
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
||||
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
||||
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
|
||||
LOAN_COB("Loan COB"), //
|
||||
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
||||
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
||||
|
||||
+11
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
|
||||
public void delete(final Office entity) {
|
||||
this.repository.delete(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
|
||||
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
|
||||
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
|
||||
* than at transaction-commit time where it can no longer be turned into a clean error message.
|
||||
*/
|
||||
public void deleteAndFlush(final Office entity) {
|
||||
this.repository.delete(entity);
|
||||
this.repository.flush();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
|
||||
// differentiate deposit accounts Savings, FD and RD accounts
|
||||
private final EnumOptionData depositType;
|
||||
|
||||
/**
|
||||
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
|
||||
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
|
||||
* for an FD that has never been renewed, and is null for non-deposit accounts.
|
||||
*/
|
||||
private final String fdAccountNumber;
|
||||
/**
|
||||
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
|
||||
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||
*/
|
||||
private final Integer renewalCycleNo;
|
||||
|
||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
||||
this.fdAccountNumber = fdAccountNumber;
|
||||
this.renewalCycleNo = renewalCycleNo;
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.externalId = externalId;
|
||||
|
||||
+3
@@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
|
||||
long countByClientId(Long clientId);
|
||||
|
||||
/** Alerts for one customer inside the recurring-behaviour observation window. */
|
||||
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/**
|
||||
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
|
||||
*
|
||||
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
|
||||
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
|
||||
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_recurring_review")
|
||||
public class AmlRecurringReview {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "alert_count_at_review", nullable = false)
|
||||
private Integer alertCountAtReview;
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 30)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "notes", nullable = false, length = 1000)
|
||||
private String notes;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "reviewed_by", nullable = false)
|
||||
private AppUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on", nullable = false)
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
protected AmlRecurringReview() {}
|
||||
|
||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
|
||||
final AmlRecurringReview review = new AmlRecurringReview();
|
||||
review.client = client;
|
||||
review.alertCountAtReview = alertCountAtReview;
|
||||
review.outcome = outcome.name();
|
||||
review.notes = notes;
|
||||
review.reviewedBy = reviewedBy;
|
||||
review.reviewedOn = LocalDateTime.now();
|
||||
return review;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public Integer getAlertCountAtReview() {
|
||||
return alertCountAtReview;
|
||||
}
|
||||
|
||||
public AmlRecurringReviewOutcome getOutcome() {
|
||||
return AmlRecurringReviewOutcome.valueOf(outcome);
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
public AppUser getReviewedBy() {
|
||||
return reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return reviewedOn;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
/** What a reviewer concluded about a customer's repeated AML activity. */
|
||||
public enum AmlRecurringReviewOutcome {
|
||||
|
||||
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
|
||||
CLEARED,
|
||||
/** Not conclusive; keep watching this customer more closely. */
|
||||
MONITOR,
|
||||
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
|
||||
ESCALATED
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
|
||||
|
||||
/** Newest first, so the caller can take the head as the latest review for a customer. */
|
||||
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
|
||||
}
|
||||
+66
@@ -84,8 +84,74 @@ public class AmlTransactionRule {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||
* A rule named for withdrawals must not fire on a deposit.
|
||||
*/
|
||||
@Column(name = "direction", length = 10)
|
||||
private String direction;
|
||||
|
||||
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||
@Column(name = "cash_only", nullable = false)
|
||||
private boolean cashOnly;
|
||||
|
||||
/**
|
||||
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||
* many small transactions; without this floor a single large one satisfies the window total and
|
||||
* reads as a pattern it is not.
|
||||
*/
|
||||
@Column(name = "min_transaction_count")
|
||||
private Integer minTransactionCount;
|
||||
|
||||
/**
|
||||
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||
* about the transaction alone and so applies to everyone.
|
||||
*/
|
||||
@Column(name = "subject_scope", length = 30)
|
||||
private String subjectScope;
|
||||
|
||||
public String getSubjectScope() {
|
||||
return subjectScope;
|
||||
}
|
||||
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public boolean isCashOnly() {
|
||||
return cashOnly;
|
||||
}
|
||||
|
||||
public Integer getMinTransactionCount() {
|
||||
return minTransactionCount;
|
||||
}
|
||||
|
||||
protected AmlTransactionRule() {}
|
||||
|
||||
/**
|
||||
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
|
||||
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
|
||||
* the two rows are always created together and the rule code is derived from it.
|
||||
*/
|
||||
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
|
||||
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
|
||||
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
|
||||
final AmlTransactionRule rule = new AmlTransactionRule();
|
||||
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
|
||||
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
|
||||
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
|
||||
rule.description = description;
|
||||
rule.appliesTo = appliesTo.name();
|
||||
rule.ruleType = ruleType.name();
|
||||
rule.windowDays = windowDays;
|
||||
rule.thresholdAmount = thresholdAmount;
|
||||
rule.minLimitAmount = minLimitAmount;
|
||||
rule.maxLimitAmount = maxLimitAmount;
|
||||
rule.active = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
+10
@@ -162,6 +162,15 @@ public class ClientApiConstants {
|
||||
public static final String ageDaysParamName = "ageDays";
|
||||
public static final String nationalityIdParamName = "nationalityId";
|
||||
public static final String isMinorParamName = "isMinor";
|
||||
/**
|
||||
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
|
||||
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
|
||||
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
|
||||
* list (with mandatory PAN) for a client the UI classifies as a minor.
|
||||
*/
|
||||
public static final int MINOR_AGE_THRESHOLD = 10;
|
||||
/** At or above this age a client qualifies for senior-citizen deposit rates. */
|
||||
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
|
||||
public static final String guardianClientIdParamName = "guardianClientId";
|
||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||
public static final String guardianRelationParamName = "guardianRelation";
|
||||
@@ -191,6 +200,7 @@ public class ClientApiConstants {
|
||||
public static final String constitutionIdParamName = "constitutionId";
|
||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||
public static final String additionalInfoParamName = "additionalInfo";
|
||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||
|
||||
|
||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
private Boolean alternateDipVerified;
|
||||
private Long alternateDipLookupLogId;
|
||||
private String alternateDipTimestamp;
|
||||
/**
|
||||
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
|
||||
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
|
||||
* reassigned would otherwise keep showing "Verified" forever.
|
||||
*/
|
||||
private String dipLiveStatus;
|
||||
private String alternateDipLiveStatus;
|
||||
/**
|
||||
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
|
||||
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
|
||||
* would keep reading "Clear" after the client was added to a later list snapshot.
|
||||
*/
|
||||
private String sanctionsAlertStatus;
|
||||
private Boolean nameVerified;
|
||||
private Long screeningLogId;
|
||||
private Boolean isStaff;
|
||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
|
||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
||||
private final CodeValueData mainBusinessLine;
|
||||
private final String remarks;
|
||||
private final String authorizedSignatory;
|
||||
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
|
||||
private final String authorizedSignatoryClientIds;
|
||||
private final String additionalInfo;
|
||||
private final LocalDate dateOfRegistration;
|
||||
|
||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.constitution = null;
|
||||
this.mainBusinessLine = null;
|
||||
this.authorizedSignatory = null;
|
||||
this.authorizedSignatoryClientIds = null;
|
||||
this.additionalInfo = null;
|
||||
this.dateOfRegistration = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
|
||||
*/
|
||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||
LocalDate dateOfRegistration) {
|
||||
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
|
||||
dateOfRegistration, null);
|
||||
}
|
||||
|
||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||
|
||||
this.constitution = constitution;
|
||||
this.incorpNumber = incorpNo;
|
||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.mainBusinessLine = mainBusinessLine;
|
||||
this.remarks = remarks;
|
||||
this.authorizedSignatory = authorizedSignatory;
|
||||
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||
this.additionalInfo = additionalInfo;
|
||||
this.dateOfRegistration = dateOfRegistration;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "is_pwd")
|
||||
private Boolean isPwd;
|
||||
|
||||
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||
private ClientKyc clientKyc;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pwd_category_cv_id")
|
||||
private CodeValue pwdCategory;
|
||||
@@ -290,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "alternate_dip_timestamp")
|
||||
private String alternateDipTimestamp;
|
||||
|
||||
/**
|
||||
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
|
||||
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
|
||||
* service was down", which previously stored both as verified.
|
||||
*/
|
||||
@Column(name = "dip_status", length = 40)
|
||||
private String dipStatus;
|
||||
|
||||
@Column(name = "alternate_dip_status", length = 40)
|
||||
private String alternateDipStatus;
|
||||
|
||||
@Column(name = "name_verified")
|
||||
private Boolean nameVerified;
|
||||
|
||||
|
||||
+416
-11
@@ -25,10 +25,15 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
@@ -60,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/**
|
||||
* Scratch record of what the in-flight {@link #update} replaced, read back via
|
||||
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
|
||||
* transaction that called update(), which clears it first.
|
||||
*/
|
||||
@Transient
|
||||
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
|
||||
|
||||
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||
@Column(name = "kyc_status", nullable = false)
|
||||
private Integer kycStatus;
|
||||
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "pan_status", length = 20)
|
||||
private String panStatus;
|
||||
|
||||
// ── PAN declaration ──────────────────────────────────────────────────────
|
||||
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
|
||||
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
|
||||
// never clear it however many times the officer retries. Rather than leave such a client
|
||||
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
|
||||
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
|
||||
|
||||
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
|
||||
@Column(name = "pan_declaration_taken")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
|
||||
@Column(name = "pan_declaration_mismatch_fields", length = 100)
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
|
||||
@Column(name = "pan_declaration_name_on_pan", length = 200)
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
/** Date of birth exactly as printed on the physical PAN card. */
|
||||
@Column(name = "pan_declaration_dob_on_pan")
|
||||
private LocalDate panDeclarationDobOnPan;
|
||||
|
||||
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
|
||||
@Column(name = "pan_declaration_remarks", length = 500)
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Column(name = "pan_declaration_taken_on")
|
||||
private LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||
@Column(name = "aadhaar_number_masked", length = 12)
|
||||
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "aadhaar_verified_on")
|
||||
private LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
|
||||
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
|
||||
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
|
||||
// itself is issued there is no number to put through the OTP flow — the check cannot pass
|
||||
// however long the branch waits. Recording the receipt is what waives the exception.
|
||||
|
||||
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
|
||||
@Column(name = "aadhaar_receipt_taken")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
|
||||
@Column(name = "aadhaar_enrolment_id", length = 40)
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
|
||||
@Column(name = "aadhaar_enrolment_date")
|
||||
private LocalDate aadhaarEnrolmentDate;
|
||||
|
||||
/** Officer's note on what was sighted. */
|
||||
@Column(name = "aadhaar_receipt_remarks", length = 500)
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Column(name = "aadhaar_receipt_taken_on")
|
||||
private LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||
@Column(name = "gstin_number", length = 15)
|
||||
private String gstinNumber;
|
||||
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "freeze_authority", length = 100)
|
||||
private String freezeAuthority;
|
||||
|
||||
@Column(name = "freeze_date")
|
||||
private LocalDate freezeDate;
|
||||
|
||||
@Column(name = "freeze_amount", precision = 19, scale = 6)
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Column(name = "unfreeze_date")
|
||||
private LocalDate unfreezeDate;
|
||||
|
||||
@Column(name = "no_debit_date")
|
||||
private LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
@Column(name = "form60_type", length = 20)
|
||||
private String form60Type;
|
||||
@@ -241,6 +321,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "remarks", length = 500)
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "is_exception", nullable = false)
|
||||
private Boolean isException;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Column(name = "exception_resolved", nullable = false)
|
||||
private Boolean exceptionResolved;
|
||||
|
||||
@Column(name = "exception_resolved_on")
|
||||
private LocalDate exceptionResolvedOn;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected ClientKyc() {}
|
||||
@@ -261,7 +353,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.gstinVerified = false;
|
||||
this.cinVerified = false;
|
||||
this.dinVerified = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||
@@ -288,6 +382,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
private void applyExtendedFields(final JsonCommand command) {
|
||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
||||
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
|
||||
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
|
||||
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
|
||||
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
|
||||
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
|
||||
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
|
||||
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
|
||||
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
|
||||
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
|
||||
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
|
||||
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
|
||||
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
|
||||
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
|
||||
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||
@@ -317,6 +429,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
||||
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
|
||||
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
|
||||
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
|
||||
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
|
||||
this.form60Type = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form60Type"), null);
|
||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||
@@ -325,15 +441,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("form15Type"), null);
|
||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form15FY"), null);
|
||||
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||
this.fieldsOverwrittenByLastUpdate.clear();
|
||||
|
||||
final String panNumberParam = "panNumber";
|
||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||
actualChanges.put(panNumberParam, newValue);
|
||||
recordOverwrite(panNumberParam, this.panNumber);
|
||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -341,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||
actualChanges.put(aadhaarParam, newValue);
|
||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -348,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||
actualChanges.put(gstinParam, newValue);
|
||||
recordOverwrite(gstinParam, this.gstinNumber);
|
||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -355,9 +482,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||
actualChanges.put(riskParam, newValue);
|
||||
recordOverwrite(riskParam, this.riskCategory);
|
||||
this.riskCategory = newValue;
|
||||
|
||||
// The re-KYC cycle is a property of the risk category, so moving the category moves the
|
||||
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
|
||||
// renewal and is never called back in for the two-yearly review the rating requires.
|
||||
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
|
||||
if (newExpiry != null) {
|
||||
actualChanges.put("expiryDate", newExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||
// as an overwrite — requiring remarks in order to write remarks would be circular.
|
||||
final String remarksParam = "remarks";
|
||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||
@@ -367,6 +505,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||
v -> this.panAadhaarSeedingStatus = v);
|
||||
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
|
||||
v -> this.panDeclarationTaken = v != null ? v : false);
|
||||
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
|
||||
v -> this.panDeclarationMismatchFields = v);
|
||||
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
|
||||
v -> this.panDeclarationNameOnPan = v);
|
||||
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
|
||||
v -> this.panDeclarationDobOnPan = v);
|
||||
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
|
||||
v -> this.panDeclarationRemarks = v);
|
||||
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
|
||||
v -> this.panDeclarationTakenOn = v);
|
||||
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
|
||||
v -> this.aadhaarReceiptTaken = v != null ? v : false);
|
||||
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
|
||||
v -> this.aadhaarEnrolmentId = v);
|
||||
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
|
||||
v -> this.aadhaarEnrolmentDate = v);
|
||||
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
|
||||
v -> this.aadhaarReceiptRemarks = v);
|
||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||
v -> this.aadhaarReceiptTakenOn = v);
|
||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||
@@ -392,12 +552,34 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||
v -> this.freezeAuthority = v);
|
||||
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
|
||||
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
|
||||
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
|
||||
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
|
||||
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
// The four exception columns are derived from whether the identity checks have passed, not
|
||||
// data an officer types, so they are updated without recording an overwrite. A verified
|
||||
// record demands remarks for any field it already held (see
|
||||
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
|
||||
// counting these would make closing a settled exception impossible without a justification
|
||||
// for something nobody chose. That is what left clients verified on the KYC tab while the
|
||||
// exceptions register still listed them as Pending.
|
||||
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
|
||||
v -> this.isException = v != null ? v : false);
|
||||
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
|
||||
v -> this.exceptionReason = v);
|
||||
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
|
||||
v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
|
||||
v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
@@ -407,6 +589,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
}
|
||||
@@ -416,6 +599,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
@@ -426,11 +610,89 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that {@code param} replaced a value the record already held. A field going from empty to
|
||||
* populated is the record being completed rather than altered, so it is deliberately not
|
||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||
*/
|
||||
/*
|
||||
* Derived-field variants of the update helpers above.
|
||||
*
|
||||
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
||||
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
||||
* kind of change a verified record demands a justification for. They still report into
|
||||
* `changes`, so the update is persisted and audited like any other.
|
||||
*/
|
||||
|
||||
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
||||
final Map<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) {
|
||||
final boolean wasPopulated = currentValue instanceof String text
|
||||
? StringUtils.isNotBlank(text)
|
||||
: currentValue != null;
|
||||
if (wasPopulated) {
|
||||
this.fieldsOverwrittenByLastUpdate.add(param);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters whose already-populated value the most recent {@link #update} replaced.
|
||||
*
|
||||
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
|
||||
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
|
||||
* still has to save the KYC document details afterwards. Those fields are empty at that point,
|
||||
* so filling them in leaves this set empty and no audit justification is demanded — while
|
||||
* changing a document number that was already on the verified record does populate it.
|
||||
*
|
||||
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
|
||||
*/
|
||||
public Set<String> getFieldsOverwrittenByLastUpdate() {
|
||||
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
|
||||
}
|
||||
|
||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||
public void markInProgress() {
|
||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||
@@ -457,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
||||
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
/** Called after successful 3rd-party verification of all required documents. */
|
||||
public void markVerified(final String ckycNumber) {
|
||||
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
||||
this.verifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.verifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||
|
||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||
@@ -472,17 +734,45 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-dates the re-KYC due date after the risk category moves.
|
||||
*
|
||||
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
|
||||
* High 2), and it is counted from the day of the change rather than from the original
|
||||
* verification. A customer moved to High in year two of a Low rating is then due in two years'
|
||||
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
|
||||
* an overdue queue the moment a re-rating ran.
|
||||
*
|
||||
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
|
||||
* {@link #markVerified} sets the date when it is first verified.
|
||||
*
|
||||
* @return the new expiry date, or {@code null} if the record carries no expiry
|
||||
*/
|
||||
private LocalDate recalculateExpiryForRiskCategory() {
|
||||
if (this.verifiedOn == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||
if (risk.getReKycYears() <= 0) {
|
||||
return this.expiryDate;
|
||||
}
|
||||
|
||||
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
|
||||
return this.expiryDate;
|
||||
}
|
||||
|
||||
/** Called when verification fails. */
|
||||
public void markRejected(final String remarks) {
|
||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||
this.rejectedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.rejectedOn = DateUtils.getBusinessLocalDate();
|
||||
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
||||
}
|
||||
|
||||
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
||||
final String aadhaarSeedingStatus, final String category, final String status) {
|
||||
this.panVerified = true;
|
||||
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
||||
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
||||
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
||||
@@ -508,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
public void markAadhaarVerified() {
|
||||
this.aadhaarVerified = true;
|
||||
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markGstinVerified() {
|
||||
this.gstinVerified = true;
|
||||
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markCinVerified() {
|
||||
this.cinVerified = true;
|
||||
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markDinVerified() {
|
||||
this.dinVerified = true;
|
||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
||||
this.dinNumber = dinNumber;
|
||||
this.dinVerified = true;
|
||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.isDirector = true;
|
||||
this.dinMcaResponse = mcaResponseJson;
|
||||
}
|
||||
@@ -550,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
} else if (type != null && type.isNonIndividual()) {
|
||||
allDone = this.panVerified && this.gstinVerified;
|
||||
}
|
||||
if (allDone && !getKycStatus().isVerified()) {
|
||||
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
|
||||
// periodic re-KYC is: an already-verified customer verified afresh.
|
||||
//
|
||||
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
|
||||
// that now measures from it — frozen at the original onboarding. An officer who re-verified
|
||||
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
|
||||
// account still sitting on the inactive report, with nothing to say what was missing.
|
||||
//
|
||||
// Guarded on the verifications rather than the status, so this only fires when the checks
|
||||
// that constitute a verification have actually been done again.
|
||||
if (allDone) {
|
||||
markVerified(this.ckycNumber);
|
||||
}
|
||||
return allDone;
|
||||
@@ -593,6 +893,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
*/
|
||||
public void escalateRiskCategory(Integer newCategory) {
|
||||
this.riskCategory = newCategory;
|
||||
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
|
||||
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
|
||||
recalculateExpiryForRiskCategory();
|
||||
}
|
||||
|
||||
public String getPanNumber() {
|
||||
@@ -631,6 +934,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return panStatus;
|
||||
}
|
||||
|
||||
public Boolean getPanDeclarationTaken() {
|
||||
return panDeclarationTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the PAN exception is waived. */
|
||||
public boolean isPanDeclarationTaken() {
|
||||
return Boolean.TRUE.equals(this.panDeclarationTaken);
|
||||
}
|
||||
|
||||
public String getPanDeclarationMismatchFields() {
|
||||
return panDeclarationMismatchFields;
|
||||
}
|
||||
|
||||
public String getPanDeclarationNameOnPan() {
|
||||
return panDeclarationNameOnPan;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationDobOnPan() {
|
||||
return panDeclarationDobOnPan;
|
||||
}
|
||||
|
||||
public String getPanDeclarationRemarks() {
|
||||
return panDeclarationRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationTakenOn() {
|
||||
return panDeclarationTakenOn;
|
||||
}
|
||||
|
||||
public Boolean getAadhaarReceiptTaken() {
|
||||
return aadhaarReceiptTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
|
||||
public boolean isAadhaarReceiptTaken() {
|
||||
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
|
||||
}
|
||||
|
||||
public String getAadhaarEnrolmentId() {
|
||||
return aadhaarEnrolmentId;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarEnrolmentDate() {
|
||||
return aadhaarEnrolmentDate;
|
||||
}
|
||||
|
||||
public String getAadhaarReceiptRemarks() {
|
||||
return aadhaarReceiptRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarReceiptTakenOn() {
|
||||
return aadhaarReceiptTakenOn;
|
||||
}
|
||||
|
||||
public String getAadhaarNumberMasked() {
|
||||
return aadhaarNumberMasked;
|
||||
}
|
||||
@@ -795,6 +1152,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public void setIsException(Boolean isException) {
|
||||
this.isException = isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public void setExceptionReason(String exceptionReason) {
|
||||
this.exceptionReason = exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
|
||||
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public LocalDate getExceptionResolvedOn() {
|
||||
return exceptionResolvedOn;
|
||||
}
|
||||
|
||||
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@@ -846,4 +1235,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public LocalDate getFreezeDate() {
|
||||
return this.freezeDate;
|
||||
}
|
||||
|
||||
public BigDecimal getFreezeAmount() {
|
||||
return this.freezeAmount;
|
||||
}
|
||||
|
||||
public LocalDate getUnfreezeDate() {
|
||||
return this.unfreezeDate;
|
||||
}
|
||||
|
||||
public LocalDate getNoDebitDate() {
|
||||
return this.noDebitDate;
|
||||
}
|
||||
}
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One customer-outreach attempt against an open KYC exception.
|
||||
*
|
||||
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||
*
|
||||
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||
* browser.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc_contact_log")
|
||||
public class ClientKycContactLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||
@Column(name = "kyc_id")
|
||||
private Long kycId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", length = 20, nullable = false)
|
||||
private KycContactChannel channel;
|
||||
|
||||
@Column(name = "notes", length = 1000, nullable = false)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "officer_name", length = 200)
|
||||
private String officerName;
|
||||
|
||||
@Column(name = "logged_by_user_id")
|
||||
private Long loggedByUserId;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||
private KycContactDeliveryStatus deliveryStatus;
|
||||
|
||||
@Column(name = "destination_masked", length = 120)
|
||||
private String destinationMasked;
|
||||
|
||||
@Column(name = "provider", length = 40)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "provider_reference", length = 120)
|
||||
private String providerReference;
|
||||
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "message_body", length = 2000)
|
||||
private String messageBody;
|
||||
|
||||
@Column(name = "contacted_at", nullable = false)
|
||||
private LocalDateTime contactedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
protected ClientKycContactLog() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||
this.client = client;
|
||||
this.kycId = kycId;
|
||||
this.channel = channel;
|
||||
this.notes = notes;
|
||||
this.officerName = officerName;
|
||||
this.loggedByUserId = loggedByUserId;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.contactedAt = contactedAt;
|
||||
this.createdAt = contactedAt;
|
||||
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||
* persisted — the entity is append-only afterwards.
|
||||
*/
|
||||
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||
final String providerReference, final String failureReason, final String messageBody) {
|
||||
this.deliveryStatus = status;
|
||||
this.destinationMasked = destinationMasked;
|
||||
this.provider = provider;
|
||||
this.providerReference = providerReference;
|
||||
this.failureReason = truncate(failureReason, 500);
|
||||
this.messageBody = truncate(messageBody, 2000);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public Long getKycId() {
|
||||
return this.kycId;
|
||||
}
|
||||
|
||||
public KycContactChannel getChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
public String getOfficerName() {
|
||||
return this.officerName;
|
||||
}
|
||||
|
||||
public Long getLoggedByUserId() {
|
||||
return this.loggedByUserId;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return this.exceptionReason;
|
||||
}
|
||||
|
||||
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||
return this.deliveryStatus;
|
||||
}
|
||||
|
||||
public String getDestinationMasked() {
|
||||
return this.destinationMasked;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public String getProviderReference() {
|
||||
return this.providerReference;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
public String getMessageBody() {
|
||||
return this.messageBody;
|
||||
}
|
||||
|
||||
public LocalDateTime getContactedAt() {
|
||||
return this.contactedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return this.createdAt;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
*/
|
||||
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||
}
|
||||
+25
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -33,9 +34,33 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||
|
||||
/**
|
||||
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||
*
|
||||
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||
* every resolved record invisible in both tabs the moment it was resolved.
|
||||
*/
|
||||
List<ClientKyc> findByExceptionResolvedTrue();
|
||||
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||
|
||||
/**
|
||||
* KYC records whose periodic re-KYC due date has passed, oldest first.
|
||||
*
|
||||
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
|
||||
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
|
||||
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
|
||||
* never took effect, and restricting an account over it would punish the wrong thing.
|
||||
*/
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
|
||||
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
||||
|
||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||
|
||||
-7
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
||||
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||
void removeSignatureId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
||||
|
||||
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
||||
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||
|
||||
+72
@@ -93,12 +93,84 @@ public class ClientRiskRule {
|
||||
@Column(name = "order_position", nullable = false)
|
||||
private Integer orderPosition;
|
||||
|
||||
/**
|
||||
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
|
||||
*
|
||||
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
|
||||
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
|
||||
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
|
||||
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
|
||||
* (Section 13).
|
||||
*
|
||||
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
|
||||
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
|
||||
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
|
||||
* rule states what it is; nothing has to guess from the shape of its other columns.
|
||||
*/
|
||||
@Column(name = "rule_kind", length = 10)
|
||||
private String ruleKind;
|
||||
|
||||
public static final String KIND_KYC = "KYC";
|
||||
public static final String KIND_AML = "AML";
|
||||
|
||||
protected ClientRiskRule() {}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
*/
|
||||
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
|
||||
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
|
||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||
final String dormantFlag, final String ruleKind) {
|
||||
final ClientRiskRule rule = new ClientRiskRule();
|
||||
rule.ruleKind = ruleKind;
|
||||
rule.ruleName = ruleName;
|
||||
rule.reason = reason;
|
||||
rule.riskCategory = riskCategory;
|
||||
rule.orderPosition = orderPosition;
|
||||
rule.legalFormId = legalFormId;
|
||||
rule.minMonthlyIncome = minMonthlyIncome;
|
||||
rule.maxMonthlyIncome = maxMonthlyIncome;
|
||||
rule.minAnnualIncome = minAnnualIncome;
|
||||
rule.maxAnnualIncome = maxAnnualIncome;
|
||||
rule.minAnnualTurnover = minAnnualTurnover;
|
||||
rule.maxAnnualTurnover = maxAnnualTurnover;
|
||||
rule.amlCheckStatus = amlCheckStatus;
|
||||
rule.bannedEntityMatch = bannedEntityMatch;
|
||||
rule.willfulDefaulter = willfulDefaulter;
|
||||
rule.ecgcSalFlag = ecgcSalFlag;
|
||||
rule.dormantFlag = dormantFlag;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public void setOccupationCategory(final CodeValue occupationCategory) {
|
||||
this.occupationCategory = occupationCategory;
|
||||
}
|
||||
|
||||
public void setOccupation(final CodeValue occupation) {
|
||||
this.occupation = occupation;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getRuleKind() {
|
||||
return ruleKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this rule may take part in onboarding risk categorisation. Rows predating the
|
||||
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
|
||||
* into the same table.
|
||||
*/
|
||||
public boolean isKycRule() {
|
||||
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
|
||||
}
|
||||
|
||||
public String getRuleName() {
|
||||
return ruleName;
|
||||
}
|
||||
|
||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
||||
|
||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
||||
|
||||
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
|
||||
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
|
||||
Integer findMaxOrderPosition();
|
||||
|
||||
boolean existsByRuleNameIgnoreCase(String ruleName);
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
/**
|
||||
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||
* KYC exception.
|
||||
*
|
||||
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||
* <ul>
|
||||
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||
* down.</li>
|
||||
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||
* actually transmit a notice to the customer at save time.</li>
|
||||
* </ul>
|
||||
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||
* received, so every call site branches on this flag rather than on the channel name.
|
||||
*/
|
||||
public enum KycContactChannel {
|
||||
|
||||
PHONE_CALL("Phone Call", false),
|
||||
SMS("SMS", true),
|
||||
EMAIL("Email", true),
|
||||
WHATSAPP("WhatsApp Message", true),
|
||||
BRANCH_VISIT("In-Branch Visit", false),
|
||||
FIELD_VISIT("Agent Field Visit", false);
|
||||
|
||||
private final String displayLabel;
|
||||
private final boolean platformSent;
|
||||
|
||||
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||
this.displayLabel = displayLabel;
|
||||
this.platformSent = platformSent;
|
||||
}
|
||||
|
||||
public String getDisplayLabel() {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||
public boolean isPlatformSent() {
|
||||
return this.platformSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||
* labels describe the *purpose* of the message, not a different channel.
|
||||
*
|
||||
* @return the matching channel, or {@code null} when nothing matches.
|
||||
*/
|
||||
public static KycContactChannel fromString(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String normalised = raw.trim().toUpperCase();
|
||||
for (final KycContactChannel channel : values()) {
|
||||
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||
KycContactChannel best = null;
|
||||
for (final KycContactChannel channel : values()) {
|
||||
final String label = channel.displayLabel.toUpperCase();
|
||||
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||
best = channel;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
/**
|
||||
* Outcome of a platform-sent KYC contact notice.
|
||||
*
|
||||
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||
*/
|
||||
public enum KycContactDeliveryStatus {
|
||||
|
||||
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||
NOT_APPLICABLE,
|
||||
|
||||
/** The gateway accepted the message for delivery. */
|
||||
SENT,
|
||||
|
||||
/** The gateway was reached but rejected the message, or the call errored. */
|
||||
FAILED,
|
||||
|
||||
/** The customer has no mobile number / email address on file for this channel. */
|
||||
SKIPPED_NO_DESTINATION,
|
||||
|
||||
/** No gateway is configured for this channel in this deployment. */
|
||||
PROVIDER_NOT_CONFIGURED;
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
/**
|
||||
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
|
||||
*
|
||||
* <p>
|
||||
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
|
||||
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
|
||||
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
|
||||
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
|
||||
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
|
||||
* a manual decision with its own notice.
|
||||
*
|
||||
* <p>
|
||||
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
|
||||
* notice" without fixing a number — so they are declared here once and read from here everywhere.
|
||||
*/
|
||||
public enum KycExpiryStage {
|
||||
|
||||
/**
|
||||
* Inside the grace window. Nothing is restricted; the customer is being chased.
|
||||
*/
|
||||
GRACE(0),
|
||||
|
||||
/**
|
||||
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
|
||||
* the customer into the branch without putting their incoming money at risk.
|
||||
*/
|
||||
PARTIAL_FREEZE(90),
|
||||
|
||||
/**
|
||||
* Both directions restricted, barring the credits that must always be let through.
|
||||
*/
|
||||
INOPERATIVE(180);
|
||||
|
||||
/** Days past the expiry date at which this stage begins. */
|
||||
private final int fromDaysOverdue;
|
||||
|
||||
KycExpiryStage(final int fromDaysOverdue) {
|
||||
this.fromDaysOverdue = fromDaysOverdue;
|
||||
}
|
||||
|
||||
public int getFromDaysOverdue() {
|
||||
return fromDaysOverdue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage an account sits at, given how far past its re-KYC due date it is.
|
||||
*
|
||||
* @param daysOverdue
|
||||
* days since the expiry date; zero or negative means the KYC has not lapsed
|
||||
* @return the stage, or {@code null} where the KYC is still current and nothing applies
|
||||
*/
|
||||
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
|
||||
if (daysOverdue < 0) {
|
||||
return null;
|
||||
}
|
||||
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
|
||||
return INOPERATIVE;
|
||||
}
|
||||
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
|
||||
return PARTIAL_FREEZE;
|
||||
}
|
||||
return GRACE;
|
||||
}
|
||||
|
||||
public boolean isGrace() {
|
||||
return this == GRACE;
|
||||
}
|
||||
|
||||
public boolean isPartialFreeze() {
|
||||
return this == PARTIAL_FREEZE;
|
||||
}
|
||||
|
||||
public boolean isInoperative() {
|
||||
return this == INOPERATIVE;
|
||||
}
|
||||
|
||||
/** Whether debits should be stopped at this stage. */
|
||||
public boolean blocksDebits() {
|
||||
return this == PARTIAL_FREEZE || this == INOPERATIVE;
|
||||
}
|
||||
|
||||
/** Whether ordinary credits should be stopped at this stage. */
|
||||
public boolean blocksCredits() {
|
||||
return this == INOPERATIVE;
|
||||
}
|
||||
}
|
||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
@Column(name = "looked_up_on", nullable = false)
|
||||
private OffsetDateTime lookedUpOn;
|
||||
|
||||
// ── Officer decision on a non-clean result ────────────────────────────────
|
||||
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
|
||||
// the officer has to say why in writing, and that reasoning is kept with the lookup.
|
||||
|
||||
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
|
||||
@Column(name = "override_decision", length = 20)
|
||||
private String overrideDecision;
|
||||
|
||||
@Column(name = "override_reason")
|
||||
private String overrideReason;
|
||||
|
||||
@Column(name = "decided_by")
|
||||
private Long decidedBy;
|
||||
|
||||
@Column(name = "decided_on")
|
||||
private OffsetDateTime decidedOn;
|
||||
|
||||
public static final String STATUS_CLEAN = "CLEAN";
|
||||
public static final String STATUS_ERROR = "ERROR";
|
||||
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
||||
public static final String DECISION_PROCEEDED = "PROCEEDED";
|
||||
public static final String DECISION_REJECTED = "REJECTED";
|
||||
|
||||
protected MobileLookupLog() {}
|
||||
|
||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
this.clientId = clientId;
|
||||
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
public void recordDecision(final String decision, final String reason, final Long userId) {
|
||||
this.overrideDecision = decision;
|
||||
this.overrideReason = reason;
|
||||
this.decidedBy = userId;
|
||||
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** True when the lookup produced something the officer must consciously decide about. */
|
||||
public boolean requiresDecision() {
|
||||
return this.suspected || this.disconnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* A number may be used only when it came back clean, or when an officer explicitly chose to
|
||||
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
|
||||
*/
|
||||
public boolean isUsable() {
|
||||
if (STATUS_CLEAN.equals(this.lookupStatus)) {
|
||||
return true;
|
||||
}
|
||||
return DECISION_PROCEEDED.equals(this.overrideDecision);
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -19,6 +19,8 @@
|
||||
package org.apache.fineract.portfolio.mobilelookup.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
|
||||
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
||||
|
||||
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
|
||||
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findPendingDecisions();
|
||||
|
||||
/**
|
||||
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
|
||||
* claim that a number was verified.
|
||||
*
|
||||
* <p>
|
||||
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
|
||||
* takes the first row.
|
||||
* </p>
|
||||
*/
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Earlier rejections of the same number, on any client. A number turned away once is a
|
||||
* standing signal: without this the next officer screens it fresh and sees no history.
|
||||
*/
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
|
||||
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
|
||||
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||
}
|
||||
|
||||
+139
@@ -61,6 +61,65 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
@Column(name = "screened_on", nullable = false)
|
||||
private OffsetDateTime screenedOn;
|
||||
|
||||
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
|
||||
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
|
||||
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
|
||||
|
||||
@Column(name = "disposition", length = 30)
|
||||
private String disposition;
|
||||
|
||||
@Column(name = "disposition_reason")
|
||||
private String dispositionReason;
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private Long reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on")
|
||||
private OffsetDateTime reviewedOn;
|
||||
|
||||
@Column(name = "approved_by")
|
||||
private Long approvedBy;
|
||||
|
||||
@Column(name = "approved_on")
|
||||
private OffsetDateTime approvedOn;
|
||||
|
||||
@Column(name = "fiu_str_reference", length = 100)
|
||||
private String fiuStrReference;
|
||||
|
||||
@Column(name = "nodal_officer_informed_on")
|
||||
private OffsetDateTime nodalOfficerInformedOn;
|
||||
|
||||
@Column(name = "frozen_account_ids", length = 1000)
|
||||
private String frozenAccountIds;
|
||||
|
||||
@Column(name = "frozen_on")
|
||||
private OffsetDateTime frozenOn;
|
||||
|
||||
// ── STR tracking ──────────────────────────────────────────────────────────
|
||||
// A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
|
||||
// AML alert is built around a monetary transaction, whereas this is triggered by who the
|
||||
// customer is, so the two are surfaced as separate streams on the FIU screen.
|
||||
|
||||
/** NULL until approval; then PENDING_FILING → FILED. */
|
||||
@Column(name = "str_status", length = 30)
|
||||
private String strStatus;
|
||||
|
||||
@Column(name = "str_due_date")
|
||||
private java.time.LocalDate strDueDate;
|
||||
|
||||
@Column(name = "str_raised_on")
|
||||
private OffsetDateTime strRaisedOn;
|
||||
|
||||
public static final String STR_PENDING_FILING = "PENDING_FILING";
|
||||
public static final String STR_FILED = "FILED";
|
||||
|
||||
public static final String RESULT_FLAGGED = "FLAGGED";
|
||||
public static final String RESULT_CLEAR = "CLEAR";
|
||||
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
|
||||
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
|
||||
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
|
||||
|
||||
protected SanctionsScreeningLog() {}
|
||||
|
||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
this.clientId = clientId;
|
||||
this.ipAddress = ipAddress;
|
||||
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
// A hit opens a case; a clear screening has no case to close.
|
||||
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
|
||||
}
|
||||
|
||||
// ── Alert lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
|
||||
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
|
||||
* Officer, so the person who raised it cannot wave their own match through.
|
||||
*/
|
||||
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
|
||||
this.disposition = newDisposition;
|
||||
this.dispositionReason = reason;
|
||||
this.reviewedBy = userId;
|
||||
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
// Re-opening a previously approved alert must drop the old approval with it.
|
||||
this.approvedBy = null;
|
||||
this.approvedOn = null;
|
||||
}
|
||||
|
||||
public void approve(final Long approverId) {
|
||||
this.approvedBy = approverId;
|
||||
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
|
||||
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
|
||||
if (strReference != null) {
|
||||
this.fiuStrReference = strReference;
|
||||
// Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
|
||||
// case stops appearing as outstanding on the FIU screen.
|
||||
if (!strReference.isBlank()) {
|
||||
this.strStatus = STR_FILED;
|
||||
}
|
||||
}
|
||||
if (nodalOfficerInformedOn != null) {
|
||||
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||
}
|
||||
}
|
||||
|
||||
public void recordFreeze(final String accountIds) {
|
||||
this.frozenAccountIds = accountIds;
|
||||
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
|
||||
* from forming the suspicion; seven calendar days is the stricter reading.
|
||||
*/
|
||||
public void raiseStr() {
|
||||
if (this.strStatus != null) {
|
||||
return;
|
||||
}
|
||||
this.strStatus = STR_PENDING_FILING;
|
||||
this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
|
||||
}
|
||||
|
||||
public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
|
||||
|
||||
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
|
||||
|
||||
public boolean isApprovedFalsePositive() {
|
||||
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
|
||||
}
|
||||
|
||||
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
public String getScreenedName() { return screenedName; }
|
||||
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||
public String getDisposition() { return disposition; }
|
||||
public String getDispositionReason() { return dispositionReason; }
|
||||
public Long getReviewedBy() { return reviewedBy; }
|
||||
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||
public Long getApprovedBy() { return approvedBy; }
|
||||
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||
public String getFiuStrReference() { return fiuStrReference; }
|
||||
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||
public String getStrStatus() { return strStatus; }
|
||||
public java.time.LocalDate getStrDueDate() { return strDueDate; }
|
||||
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
|
||||
}
|
||||
|
||||
+28
@@ -39,4 +39,32 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
|
||||
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
||||
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
|
||||
|
||||
/** Every open alert — the Principal Officer's review queue. */
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findAllAlerts();
|
||||
|
||||
/**
|
||||
* A name is only cleared to onboard once a false positive on that exact name has been
|
||||
* approved by the Principal Officer — the maker's own assessment is not enough.
|
||||
*/
|
||||
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
|
||||
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
|
||||
long countApprovedFalsePositives(@Param("name") String name);
|
||||
|
||||
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
||||
+ "ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
|
||||
* Pass null for every STR regardless of whether it has been filed.
|
||||
*/
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
|
||||
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
|
||||
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
|
||||
}
|
||||
|
||||
+7
-1
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
|
||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
|
||||
case 200 -> TRANSFER_TO_SAVINGS;
|
||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||
case 500 -> PARTIAL_REINVEST;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
|
||||
return this.equals(INVALID);
|
||||
}
|
||||
|
||||
public boolean isPartialReinvest() {
|
||||
return this.equals(PARTIAL_REINVEST);
|
||||
}
|
||||
|
||||
// TODO: do we really need this?!?
|
||||
public static Object[] integerValues() {
|
||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||
|
||||
+5
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
|
||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||
public static final String transferDescriptionParamName = "transferDescription";
|
||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||
public static final String savingsAccounts = "savingsAccounts";
|
||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
|
||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
|
||||
fixedDepositResponseData.add("projectedInterestSchedule");
|
||||
fixedDepositResponseData.add("totalProjectedInterest");
|
||||
fixedDepositResponseData.add("totalValueAtMaturity");
|
||||
return fixedDepositResponseData;
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
|
||||
public static final String statusParamName = "status";
|
||||
public static final String subStatusParamName = "subStatus";
|
||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
||||
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
|
||||
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
|
||||
public static final String holdCategoryParamName = "holdCategory";
|
||||
public static final String clientIdParamName = "clientId";
|
||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
|
||||
public static final String endDateParamName = "endDate";
|
||||
public static final String introducerRequiredParamName = "introducerRequired";
|
||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||
public static final String shareAccountIdParamName = "shareAccountId";
|
||||
public static final String nominationFacilityParamName = "nominationFacility";
|
||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||
@@ -254,7 +258,6 @@ public class SavingsApiConstants {
|
||||
public static final String neftFacilityParamName = "neftFacility";
|
||||
public static final String rtgsFacilityParamName = "rtgsFacility";
|
||||
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
||||
public static final String withdrawalFacilityParamName = "withdrawalFacility";
|
||||
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
||||
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
||||
|
||||
|
||||
+17
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
|
||||
private LocalDate endDate;
|
||||
private Boolean introducerRequired;
|
||||
private Boolean shareLinkingEnabled;
|
||||
private Long shareAccountId;
|
||||
private String shareAccountNo;
|
||||
private Boolean nominationFacility;
|
||||
private Boolean dormancyAlert;
|
||||
private Boolean isDormancyTrackingActiveFromProduct;
|
||||
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
|
||||
private BigDecimal interestPosting;
|
||||
private BigDecimal overdraftPosting;
|
||||
|
||||
/**
|
||||
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
|
||||
* Such interest never stays in the account, so it must never compound -- every period earns on the
|
||||
* principal alone. Transient because it is only read by the batch interest-posting engine.
|
||||
*/
|
||||
private transient boolean transferInterestToLinkedAccount;
|
||||
|
||||
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
|
||||
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
public boolean isTransferInterestToLinkedAccount() {
|
||||
return this.transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||
|
||||
+10
-1
@@ -256,7 +256,16 @@ public final class PostingPeriod {
|
||||
this.daysInYear = daysInYear;
|
||||
this.compoundingPeriods = compoundingPeriods;
|
||||
|
||||
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||
if (isUserPosting) {
|
||||
// "Post interest as on D" — a closure, or the same action from the account screen. The
|
||||
// period it covers ends at D-1, because interest is earned up to but not including the
|
||||
// day it is paid, and SavingsHelper closes the period there. The transaction itself
|
||||
// still belongs on D: that is the day the member was credited, the day the branch did
|
||||
// the work, and the day the cash is handed over. Dating it to the period end instead
|
||||
// stamps it a day before the account was even closed, and the ledger then disagrees with
|
||||
// the closure request, the voucher and the passbook — all of which carry D.
|
||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||
this.dateOfPostingTransaction = periodInterval.endDate();
|
||||
} else {
|
||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||
|
||||
+4
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||
break;
|
||||
case PARTIAL_REINVEST:
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||
break;
|
||||
}
|
||||
return optionData;
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
-4
@@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
||||
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
||||
private boolean interBankTotalCapFacility = false;
|
||||
|
||||
@Column(name = "withdrawal_facility", nullable = false)
|
||||
private boolean withdrawalFacility = false;
|
||||
|
||||
@Column(name = "deposit_facility_kyc", nullable = false)
|
||||
private boolean depositFacilityKyc = false;
|
||||
|
||||
@@ -120,7 +117,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
||||
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
||||
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
||||
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
|
||||
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
|
||||
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
||||
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
||||
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
||||
|
||||
-3
@@ -136,9 +136,6 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "inter_bank_total_cap_facility")
|
||||
private Boolean interBankTotalCapFacility;
|
||||
|
||||
@Column(name = "withdrawal_facility")
|
||||
private Boolean withdrawalFacility;
|
||||
|
||||
@Column(name = "deposit_facility_kyc")
|
||||
private Boolean depositFacilityKyc;
|
||||
|
||||
|
||||
-1
@@ -378,7 +378,6 @@ public class CurrentProductAssembler {
|
||||
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
||||
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
||||
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
||||
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
|
||||
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
||||
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
||||
return config;
|
||||
|
||||
-6
@@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
||||
@Column(name = "inter_bank_total_cap_facility")
|
||||
private Boolean interBankTotalCapFacility = false;
|
||||
|
||||
@Column(name = "withdrawal_facility")
|
||||
private Boolean withdrawalFacility = false;
|
||||
|
||||
@Column(name = "deposit_facility_kyc")
|
||||
private Boolean depositFacilityKyc = false;
|
||||
|
||||
@@ -125,8 +122,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
||||
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
||||
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
||||
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
||||
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
|
||||
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
|
||||
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
||||
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
||||
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
||||
@@ -147,7 +142,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
||||
this.neftFacility = other.getNeftFacility();
|
||||
this.rtgsFacility = other.getRtgsFacility();
|
||||
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
||||
this.withdrawalFacility = other.getWithdrawalFacility();
|
||||
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
||||
this.virtualDebitCard = other.getVirtualDebitCard();
|
||||
}
|
||||
|
||||
-1
@@ -117,7 +117,6 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
||||
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
||||
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
||||
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
||||
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
|
||||
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
||||
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
||||
product.setAccountFacilityConfig(facilityConfigMap);
|
||||
|
||||
+10
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.contentstore.policy;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public class MimeContentPolicy implements ContentPolicy {
|
||||
|
||||
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
|
||||
// detects it as text/plain even though it was uploaded (and named) as text/csv.
|
||||
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
|
||||
|
||||
private final ContentDetectorManager contentDetectorManager;
|
||||
|
||||
@Override
|
||||
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
|
||||
var result = contentDetectorManager
|
||||
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
|
||||
|
||||
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
|
||||
boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|
||||
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
|
||||
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
|
||||
|
||||
if (!matches) {
|
||||
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
|
||||
result.getMimeType(), ctx.getMimeType()));
|
||||
}
|
||||
|
||||
+29
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
@Deprecated
|
||||
private final List<EntityImageIdAdapter> imageIdAdapters;
|
||||
private final ContentStoreService storeService;
|
||||
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_image row survives but the file it points at does not — the usual cause is a
|
||||
// database copied between environments without its file store. For the caller that is
|
||||
// simply "this entity has no image", not a server fault, so report it the same way as a
|
||||
// missing image record instead of letting it surface as a 500.
|
||||
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
|
||||
entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, -1L);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.dividendpaidreport.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
|
||||
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
|
||||
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
|
||||
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
|
||||
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
|
||||
* is, by construction, a real dividend payment.
|
||||
*/
|
||||
@Path("/v1/accounting/dividend-paid-report")
|
||||
@Component
|
||||
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendPaidReportApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("fromDate", fromDate);
|
||||
result.addProperty("toDate", toDate);
|
||||
if (payableDividendsAccountId == null) {
|
||||
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
|
||||
result.addProperty("totalPaid", BigDecimal.ZERO);
|
||||
result.addProperty("payoutCount", 0);
|
||||
result.add("entries", new JsonArray());
|
||||
result.addProperty("glAccountConfigured", false);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
|
||||
SELECT entry_date, amount, transaction_id
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
ORDER BY entry_date DESC
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
|
||||
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
|
||||
result.addProperty("glAccountConfigured", true);
|
||||
|
||||
final JsonArray entries = new JsonArray();
|
||||
for (final Map<String, Object> row : entryRows) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("entryDate", row.get("entry_date").toString());
|
||||
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||
entry.addProperty("transactionId", (String) row.get("transaction_id"));
|
||||
entries.add(entry);
|
||||
}
|
||||
result.add("entries", entries);
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject errorBody = new JsonObject();
|
||||
errorBody.addProperty("message", message);
|
||||
errorBody.addProperty("defaultUserMessage", message);
|
||||
errorBody.addProperty("httpStatus", httpStatus);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.dividendpaidreport.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
|
||||
* how much is still unclaimed, and how much has come back to profit.
|
||||
*
|
||||
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
|
||||
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
|
||||
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
|
||||
* money moved; the locks are what it moved for.
|
||||
*
|
||||
* The columns mirror the money's actual path, which begins and ends in the same place:
|
||||
*
|
||||
* <pre>
|
||||
* declare Dr Bank Profit Cr Dividend Payable
|
||||
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
|
||||
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
|
||||
* </pre>
|
||||
*
|
||||
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
|
||||
* is what is still sitting unclaimed and still within its three-year window.
|
||||
*/
|
||||
@Path("/v1/accounting/dividend-yearwise-report")
|
||||
@Component
|
||||
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendYearwiseReportApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String report() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
|
||||
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
|
||||
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
|
||||
// through the per-member detail rather than counted off the declaration, because a lock is only
|
||||
// created for a member who actually had something to collect.
|
||||
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
|
||||
// Summing the declaration amount across a join to its locks would multiply it by the number
|
||||
// of members it was split between.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
WITH fy AS (
|
||||
SELECT d.id,
|
||||
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
|
||||
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
|
||||
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
|
||||
d.amount
|
||||
FROM m_share_product_dividend_pay_out d
|
||||
),
|
||||
declared AS (
|
||||
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
|
||||
FROM fy GROUP BY fy_start
|
||||
),
|
||||
outcomes AS (
|
||||
SELECT fy.fy_start,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
|
||||
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
|
||||
FROM fy
|
||||
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
|
||||
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
||||
GROUP BY fy.fy_start
|
||||
)
|
||||
SELECT d.fy_start, d.declaration_count, d.declared_amount,
|
||||
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
|
||||
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
|
||||
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
|
||||
o.earliest_release_due
|
||||
FROM declared d
|
||||
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
|
||||
ORDER BY d.fy_start DESC
|
||||
""");
|
||||
|
||||
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
|
||||
// member, so no lock was ever created for it. Pulled separately and matched by year.
|
||||
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
|
||||
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
|
||||
THEN EXTRACT(YEAR FROM period_end_date)
|
||||
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
|
||||
COALESCE(SUM(residue_amount), 0) AS residue
|
||||
FROM m_share_dividend_rounding_residue
|
||||
GROUP BY fy_start
|
||||
""")) {
|
||||
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> years = new ArrayList<>();
|
||||
BigDecimal totalDeclared = BigDecimal.ZERO;
|
||||
BigDecimal totalClaimed = BigDecimal.ZERO;
|
||||
BigDecimal totalUnclaimed = BigDecimal.ZERO;
|
||||
BigDecimal totalForfeited = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
|
||||
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
|
||||
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
|
||||
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
|
||||
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
|
||||
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
|
||||
|
||||
final Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("financialYear", financialYear);
|
||||
entry.put("declarationCount", row.get("declaration_count"));
|
||||
entry.put("declaredAmount", declared);
|
||||
entry.put("claimedAmount", claimed);
|
||||
entry.put("claimedCount", row.get("claimed_count"));
|
||||
entry.put("unclaimedAmount", unclaimed);
|
||||
entry.put("unclaimedCount", row.get("unclaimed_count"));
|
||||
entry.put("forfeitedAmount", forfeited);
|
||||
entry.put("forfeitedCount", row.get("forfeited_count"));
|
||||
entry.put("roundingResidueAmount", residue);
|
||||
// What has already gone back to profit for this year: forfeited entitlements plus the
|
||||
// rounding residue swept at declaration time.
|
||||
entry.put("returnedToProfitAmount", forfeited.add(residue));
|
||||
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
|
||||
// amount starts returning to profit.
|
||||
entry.put("earliestReleaseDueDate",
|
||||
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
|
||||
years.add(entry);
|
||||
|
||||
totalDeclared = totalDeclared.add(declared);
|
||||
totalClaimed = totalClaimed.add(claimed);
|
||||
totalUnclaimed = totalUnclaimed.add(unclaimed);
|
||||
totalForfeited = totalForfeited.add(forfeited);
|
||||
}
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("years", years);
|
||||
response.put("totalDeclared", totalDeclared);
|
||||
response.put("totalClaimed", totalClaimed);
|
||||
response.put("totalUnclaimed", totalUnclaimed);
|
||||
response.put("totalForfeited", totalForfeited);
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
private String financialYearLabel(final int startYear) {
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -25,12 +25,14 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
||||
@@ -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.shareaccounts.data.ShareAccountTransactionEnumData;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class AccountingProcessorHelper {
|
||||
@@ -89,6 +92,14 @@ public class AccountingProcessorHelper {
|
||||
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
||||
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
||||
|
||||
/**
|
||||
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
|
||||
* posting is routed through. The names are the contract with the product screen; the GL codes
|
||||
* behind them are the bank's choice, so no code is named here.
|
||||
*/
|
||||
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
|
||||
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
|
||||
|
||||
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
||||
|
||||
public static void setDepositAccountTypeContext(int depositAccountType) {
|
||||
@@ -116,6 +127,10 @@ public class AccountingProcessorHelper {
|
||||
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
||||
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
|
||||
// JSON document rather than in acc_product_mapping, so they are read directly. See
|
||||
// namedSavingsGlAccount.
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public LoanDTO populateLoanDtoFromDTO(
|
||||
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
||||
@@ -600,6 +615,144 @@ public class AccountingProcessorHelper {
|
||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest credited to a member, routed through the payable and paid accounts the product names.
|
||||
*
|
||||
* <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),
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
final Long paymentTypeId) {
|
||||
GLAccount glAccount;
|
||||
@@ -1263,6 +1439,11 @@ public class AccountingProcessorHelper {
|
||||
accountMapping = paymentChannelSpecificAccountMapping;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountMapping == null) {
|
||||
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
|
||||
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
|
||||
}
|
||||
glAccount = accountMapping.getGlAccount();
|
||||
}
|
||||
return glAccount;
|
||||
|
||||
+6
-3
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
// Routed through the payable and paid accounts the product names, so the society
|
||||
// can read interest declared but not yet credited as its own balance. Collapses
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+132
-1
@@ -21,9 +21,11 @@ package org.apache.fineract.accounting.journalentry.service;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -104,6 +106,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.NonTransientDataAccessException;
|
||||
@@ -136,6 +140,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||
private final LoanTransactionRepository loanTransactionRepository;
|
||||
private final VoucherService voucherService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -232,14 +237,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
|
||||
}
|
||||
|
||||
// Raise the voucher from the entries that were actually written, rather than from the
|
||||
// command: an accounting rule can supply a side the request never named, so reading
|
||||
// back by transaction id is the only view that matches what the ledger now holds.
|
||||
final String voucherNo = raiseJournalVoucher(transactionId);
|
||||
if (voucherNo != null) {
|
||||
changes.put("voucherNo", voucherNo);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||
.withTransactionId(transactionId).build();
|
||||
.withTransactionId(transactionId).with(changes).build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
final Throwable throwable = dve.getMostSpecificCause();
|
||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a manual journal in the voucher register and returns its number.
|
||||
*
|
||||
* The sides are read back from the entries just written rather than taken from the request,
|
||||
* because an accounting rule can contribute a debit or credit the caller never named; only the
|
||||
* persisted rows show what the ledger actually holds.
|
||||
*
|
||||
* A failure here does not fail the journal. The entries are already posted and correct, and
|
||||
* refusing them because the register could not be written would reject a sound accounting
|
||||
* transaction over its paperwork. The problem is logged and the journal stands without a
|
||||
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
|
||||
*/
|
||||
private String raiseJournalVoucher(final String transactionId) {
|
||||
// A register that posts its ledger movement through this command raises its own voucher,
|
||||
// naming the member and the instrument. Raising a journal one beside it would put the same
|
||||
// money in the register twice.
|
||||
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final List<JournalEntry> entries = this.glJournalEntryRepository
|
||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||
if (CollectionUtils.isEmpty(entries)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
for (final JournalEntry entry : entries) {
|
||||
final GLAccount account = entry.getGlAccount();
|
||||
final Long accountId = account == null ? null : account.getId();
|
||||
final String name = account == null ? null : account.getName();
|
||||
final String code = account == null ? null : account.getGlCode();
|
||||
|
||||
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
|
||||
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
|
||||
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
|
||||
}
|
||||
|
||||
// Debits first, so the voucher prints the way a slip is read.
|
||||
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
|
||||
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||
/**
|
||||
@@ -328,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
|
||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
|
||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
||||
|
||||
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
||||
validateNoDenominationManagedCashAccounts(journalEntryCommand);
|
||||
|
||||
/**
|
||||
* revert old journal entries
|
||||
*
|
||||
* A transaction that carries a leg against the denomination-managed cash account (see
|
||||
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
|
||||
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
|
||||
* entry against this same Opening Balances Contra account), so the query above finds it
|
||||
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
|
||||
* leg, would silently erase the cash account's opening balance every time an admin
|
||||
* redefines opening balances for any OTHER account at this office.
|
||||
*/
|
||||
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
||||
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
||||
officeId);
|
||||
for (String transactionId : transactionIdsToBeReversed) {
|
||||
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
|
||||
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
|
||||
if (touchesDenominationManagedCashAccount) {
|
||||
continue;
|
||||
}
|
||||
revertJournalEntry(journalEntries, "defining opening balance");
|
||||
}
|
||||
|
||||
@@ -802,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
|
||||
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
||||
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
||||
|
||||
+6
-4
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -67,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
|
||||
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
||||
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
||||
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
||||
BusinessEventNotifierService businessEventNotifierService) {
|
||||
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
|
||||
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
||||
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
||||
businessEventNotifierService);
|
||||
businessEventNotifierService, jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
|
||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||
LoanTransactionRepository loanTransactionRepository) {
|
||||
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
||||
voucherService);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||
}
|
||||
|
||||
// Optional: only mapped when the product screen supplies it. Every savings product that
|
||||
// existed before this row was added has none, and a closure on one of those still has to
|
||||
// work — see the closure's own fallback.
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
|
||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element,
|
||||
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId,
|
||||
CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue());
|
||||
}
|
||||
|
||||
// advanced accounting mappings
|
||||
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
||||
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.profitandloss.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* How much profit is available to fund a share dividend, for a given period.
|
||||
*
|
||||
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
|
||||
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
|
||||
* for another share product over the same period, since they all draw on the same balance.
|
||||
*
|
||||
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
|
||||
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
|
||||
* second time would hold back money that has already left the account.
|
||||
*
|
||||
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
||||
*/
|
||||
@Path("/v1/accounting/distributable-profit")
|
||||
@Component
|
||||
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
|
||||
@RequiredArgsConstructor
|
||||
public class DistributableProfitApiResource {
|
||||
|
||||
/**
|
||||
* The year's closed-out profit, which is what a dividend may be declared against.
|
||||
*
|
||||
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* offer profit already distributed once for distribution again.
|
||||
*/
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("excludeProductId") final Long excludeProductId) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
if (to.before(from)) {
|
||||
throw apiError(400, "toDate must not be before fromDate");
|
||||
}
|
||||
|
||||
// A dividend is normally declared for a whole, closed financial year, and then the audited
|
||||
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
|
||||
// only be an interim declaration, which necessarily runs against the still-open P&L.
|
||||
final String financialYear = financialYearOf(from.toLocalDate());
|
||||
final LocalDate[] fyRange = financialYearRange(financialYear);
|
||||
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
|
||||
|
||||
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
|
||||
final boolean yearClosed = closedProfit != null;
|
||||
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
|
||||
|
||||
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
||||
|
||||
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
|
||||
// the profit-distribution screen before any dividend is declared, so the profit this reads
|
||||
// has already had them taken out — subtracting them again would hold back money that is no
|
||||
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
|
||||
// than a negative figure a caller could read as a credit.
|
||||
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
|
||||
|
||||
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
|
||||
BigDecimal alreadyDeclared = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> declaration : existingDeclarations) {
|
||||
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
|
||||
}
|
||||
|
||||
// An over-declaration that already happened (or appropriation rates raised after the fact)
|
||||
// must not present as negative headroom, which a caller could read as a credit.
|
||||
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("fromDate", fromDate);
|
||||
response.put("toDate", toDate);
|
||||
response.put("financialYear", financialYear);
|
||||
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
|
||||
response.put("yearClosed", yearClosed);
|
||||
// Names the figure so the screen can say where it came from — the closed books, or a P&L
|
||||
// that is still moving — rather than showing a bare number the officer has to interpret.
|
||||
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
|
||||
response.put("netProfit", netProfit);
|
||||
response.put("distributableProfit", distributableProfit);
|
||||
response.put("alreadyDeclared", alreadyDeclared);
|
||||
response.put("availableForDeclaration", availableForDeclaration);
|
||||
response.put("maximumRatePercentage", maximumRatePercentage);
|
||||
response.put("existingDeclarations", existingDeclarations);
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The year's profit as the books actually record it, once the year has been closed.
|
||||
*
|
||||
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
|
||||
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
|
||||
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
|
||||
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
|
||||
*
|
||||
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
|
||||
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
|
||||
* figure that is still moving.
|
||||
*/
|
||||
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
|
||||
final Integer closedCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
|
||||
if (closedCount == null || closedCount == 0) {
|
||||
return null;
|
||||
}
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
|
||||
java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
|
||||
private LocalDate[] financialYearRange(final String financialYear) {
|
||||
try {
|
||||
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||
} catch (final RuntimeException e) {
|
||||
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
|
||||
private String financialYearOf(final LocalDate date) {
|
||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
|
||||
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
|
||||
*/
|
||||
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_income,
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_expense
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE je.reversed = false
|
||||
AND je.entry_date BETWEEN ? AND ?
|
||||
""", from, to);
|
||||
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||
return totalIncome.subtract(totalExpense);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
|
||||
* society-wide, so a second product's declaration competes for the same balance as the first.
|
||||
*
|
||||
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
||||
* declaration is separately blocked outright.
|
||||
*/
|
||||
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
|
||||
d.dividend_period_start_date, d.dividend_period_end_date, d.status
|
||||
FROM m_share_product_dividend_pay_out d
|
||||
JOIN m_share_product p ON p.id = d.product_id
|
||||
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(to, from));
|
||||
if (excludeProductId != null) {
|
||||
sql.append(" AND d.product_id <> ? ");
|
||||
params.add(excludeProductId);
|
||||
}
|
||||
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
|
||||
|
||||
final List<Map<String, Object>> declarations = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> declaration = new LinkedHashMap<>();
|
||||
declaration.put("declarationId", row.get("declaration_id"));
|
||||
declaration.put("productId", row.get("product_id"));
|
||||
declaration.put("productName", row.get("product_name"));
|
||||
declaration.put("amount", row.get("amount"));
|
||||
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
|
||||
declaration.put("status", row.get("status"));
|
||||
declarations.add(declaration);
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
|
||||
* which is 0 — not a failure. Only an enabled row with a value applies.
|
||||
*/
|
||||
private BigDecimal configuredPercentage(final String configurationName) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
|
||||
if (rows.isEmpty() || rows.get(0).get("value") == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.profitandloss.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Real Profit & Loss (Net Profit) for a date range, computed live from posted General Ledger
|
||||
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
|
||||
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
|
||||
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
|
||||
*
|
||||
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
|
||||
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
|
||||
* and decreases with a CREDIT. Reversed entries are excluded.
|
||||
*/
|
||||
@Path("/v1/accounting/profitandloss")
|
||||
@Component
|
||||
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
|
||||
@RequiredArgsConstructor
|
||||
public class ProfitAndLossApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_income,
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_expense
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE je.reversed = false
|
||||
AND je.entry_date BETWEEN ? AND ?
|
||||
""", from, to);
|
||||
|
||||
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||
|
||||
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
|
||||
"netProfit", netProfit));
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package org.apache.fineract.accounting.yearend.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
|
||||
*
|
||||
* <p>
|
||||
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
|
||||
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
|
||||
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
|
||||
*/
|
||||
@Path("/v1/accounting/incomeprofittransfer")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class IncomeProfitTransferApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final IncomeProfitTransferService service;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — how much is waiting to be posted, and what has been posted before
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
|
||||
@QueryParam("financialYear") final String financialYearParam) {
|
||||
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
|
||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: incomeGlCodeParam.trim();
|
||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
|
||||
|
||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||
: financialYearParam.trim();
|
||||
final LocalDate[] range = service.financialYearRange(financialYear);
|
||||
|
||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||
final BigDecimal pending = service.balanceOf(incomeGlId, range);
|
||||
final int entryCount = service.entryCountOf(incomeGlId, range);
|
||||
|
||||
final List<Map<String, Object>> history = jdbcTemplate
|
||||
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
|
||||
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
|
||||
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
|
||||
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
|
||||
|
||||
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("fromDate", range[0].toString());
|
||||
out.put("toDate", range[1].toString());
|
||||
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||
out.put("incomeGlName", incomeGl.get("name"));
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
out.put("pendingAmount", pending);
|
||||
out.put("entryCount", entryCount);
|
||||
out.put("alreadyPosted", alreadyPosted);
|
||||
out.put("postedHistory", history);
|
||||
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — close the income GL into Bank Profit
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Transactional
|
||||
public Response post(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: getString(json, "incomeGlCode").trim();
|
||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||
: getString(json, "financialYear").trim();
|
||||
|
||||
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
|
||||
// error, so the user is told why nothing happened.
|
||||
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||
sanitiseNote(getString(json, "note")), true);
|
||||
|
||||
return Response.ok(gson.toJson(result)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST /close-year — close the WHOLE P&L into Bank Profit
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
|
||||
*
|
||||
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
|
||||
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
|
||||
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
|
||||
*/
|
||||
@POST
|
||||
@Path("/close-year")
|
||||
@Transactional
|
||||
public Response closeYear(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||
: getString(json, "financialYear").trim();
|
||||
|
||||
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
|
||||
// told why nothing happened rather than getting a silent success.
|
||||
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||
sanitiseNote(getString(json, "note")), true);
|
||||
|
||||
return Response.ok(gson.toJson(result)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private JsonObject parseBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitiseNote(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = raw.trim();
|
||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
||||
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class PostShareEntryFeeToProfitConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private IncomeProfitTransferService incomeProfitTransferService;
|
||||
|
||||
@Bean
|
||||
protected Step postShareEntryFeeToProfitStep() {
|
||||
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
|
||||
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job postShareEntryFeeToProfitJob() {
|
||||
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
|
||||
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
||||
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
|
||||
* Profit equity GL, leaving the income account at nil for the new year.
|
||||
*
|
||||
* <p>
|
||||
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
|
||||
* rather than failing the job. Only a genuine posting failure is raised.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
|
||||
|
||||
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
|
||||
private static final long SYSTEM_USER_ID = 1L;
|
||||
|
||||
private final IncomeProfitTransferService incomeProfitTransferService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
final String financialYear = incomeProfitTransferService.currentFinancialYear();
|
||||
|
||||
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
|
||||
// is now what the share dividend declaration reads as "the year's profit", and a partial close
|
||||
// would leave most of the profit still sitting in the income GLs — understating what may be
|
||||
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
|
||||
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
|
||||
"Automatic year-end closing", false);
|
||||
|
||||
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
package org.apache.fineract.accounting.yearend.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.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.service.DateUtils;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Closes an income GL into the Bank Profit equity GL at financial year end.
|
||||
*
|
||||
* <pre>
|
||||
* Dr Share Entry Fee Income (10.4) = balance for the year
|
||||
* Cr Distributable Profit (the GL mapped to that financial activity)
|
||||
* </pre>
|
||||
*
|
||||
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
||||
* retained profit.
|
||||
*
|
||||
* <p>
|
||||
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
|
||||
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
|
||||
* logged-in user.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class IncomeProfitTransferService {
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
||||
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* The year's profit is closed into the Distributable Profit account, not the Retained one.
|
||||
*
|
||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit
|
||||
* once the declaration is approved. Closing into Retained directly would mix the year just ended with earlier
|
||||
* years' leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||
* decision — offering the same money for distribution twice.
|
||||
*
|
||||
* Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01.
|
||||
*/
|
||||
private final DividendGlAccountResolver dividendGlAccounts;
|
||||
|
||||
/**
|
||||
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
||||
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
||||
* errors.
|
||||
*
|
||||
* @param strict
|
||||
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
|
||||
* returns quietly.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
|
||||
final String note, final boolean strict) {
|
||||
|
||||
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Long officeId = headOfficeId();
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||
out.put("incomeGlName", incomeGl.get("name"));
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
|
||||
final Integer existing = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||
Integer.class, financialYear, incomeGlId, officeId);
|
||||
if (existing != null && existing > 0) {
|
||||
if (strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
|
||||
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
|
||||
}
|
||||
out.put("posted", false);
|
||||
out.put("reason", "Already closed for " + financialYear);
|
||||
return out;
|
||||
}
|
||||
|
||||
final BigDecimal balance = balanceOf(incomeGlId, range);
|
||||
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
if (strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
|
||||
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
|
||||
}
|
||||
out.put("posted", false);
|
||||
out.put("reason", "No income to close for " + financialYear);
|
||||
return out;
|
||||
}
|
||||
|
||||
final int entryCount = entryCountOf(incomeGlId, range);
|
||||
final String currencyCode = resolveCurrency(incomeGlId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YEPROFIT-" + System.nanoTime();
|
||||
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
|
||||
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Profit for " + financialYear + " from " + incomeGl.get("name"));
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_income_profit_transfer "
|
||||
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
|
||||
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||
|
||||
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
|
||||
financialYear);
|
||||
|
||||
out.put("posted", true);
|
||||
out.put("amountPostedToProfit", balance);
|
||||
out.put("entryCount", entryCount);
|
||||
out.put("postedOnDate", postingDate.toString());
|
||||
out.put("glTransactionId", txnId);
|
||||
out.put("currency", currencyCode);
|
||||
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
|
||||
* net profit.
|
||||
*
|
||||
* <pre>
|
||||
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
|
||||
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
|
||||
* </pre>
|
||||
*
|
||||
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
|
||||
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
|
||||
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
|
||||
* profit split across two places and understates it.
|
||||
*
|
||||
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
|
||||
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
|
||||
* Accounts with a nil balance are skipped rather than posted as zero entries.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
||||
final boolean strict) {
|
||||
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Long officeId = headOfficeId();
|
||||
|
||||
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
|
||||
// historical balance but must not attract new postings.
|
||||
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
|
||||
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
|
||||
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
|
||||
|
||||
final List<Map<String, Object>> closed = new ArrayList<>();
|
||||
final List<Map<String, Object>> skipped = new ArrayList<>();
|
||||
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> gl : plAccounts) {
|
||||
final Long glId = toLong(gl.get("id"));
|
||||
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
|
||||
|
||||
final Integer existing = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||
Integer.class, financialYear, glId, officeId);
|
||||
if (existing != null && existing > 0) {
|
||||
skipped.add(skipRow(gl, "Already closed for " + financialYear));
|
||||
continue;
|
||||
}
|
||||
|
||||
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
|
||||
// expense GL the natural balance is a debit, so the same figure comes back negative and
|
||||
// is flipped here to read as a positive expense.
|
||||
final BigDecimal rawBalance = balanceOf(glId, range);
|
||||
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
|
||||
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
|
||||
continue;
|
||||
}
|
||||
|
||||
final int entryCount = entryCountOf(glId, range);
|
||||
final String currencyCode = resolveCurrency(glId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
|
||||
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
if (isIncome) {
|
||||
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Profit for " + financialYear + " from " + gl.get("name"));
|
||||
totalIncome = totalIncome.add(balance);
|
||||
} else {
|
||||
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Expense for " + financialYear + " from " + gl.get("name"));
|
||||
totalExpense = totalExpense.add(balance);
|
||||
}
|
||||
|
||||
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
|
||||
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
|
||||
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
|
||||
java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("glCode", gl.get("gl_code"));
|
||||
row.put("glName", gl.get("name"));
|
||||
row.put("type", isIncome ? "INCOME" : "EXPENSE");
|
||||
row.put("amount", balance);
|
||||
row.put("entryCount", entryCount);
|
||||
row.put("glTransactionId", txnId);
|
||||
closed.add(row);
|
||||
}
|
||||
|
||||
if (closed.isEmpty() && strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
|
||||
"There is nothing left to close to profit for " + financialYear + ".");
|
||||
}
|
||||
|
||||
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
|
||||
netProfit, profitGl.get("name"));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("fromDate", range[0].toString());
|
||||
out.put("toDate", range[1].toString());
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
out.put("totalIncomeClosed", totalIncome);
|
||||
out.put("totalExpenseClosed", totalExpense);
|
||||
out.put("netProfitPosted", netProfit);
|
||||
out.put("accountsClosed", closed);
|
||||
out.put("accountsSkipped", skipped);
|
||||
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
|
||||
+ netProfit + ".");
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("glCode", gl.get("gl_code"));
|
||||
row.put("glName", gl.get("name"));
|
||||
row.put("reason", reason);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
|
||||
* year has been closed.
|
||||
*
|
||||
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
|
||||
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
|
||||
* whether that is a blocking condition.
|
||||
*/
|
||||
public BigDecimal profitForFinancialYear(final String financialYear) {
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
||||
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** Whether the year's P&L has been closed into Bank Profit at all. */
|
||||
public boolean isYearClosed(final String financialYear) {
|
||||
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
|
||||
Integer.class, financialYear);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** Net credit balance sitting in the income GL for the year. */
|
||||
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
|
||||
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** How many fee postings made up that balance. */
|
||||
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
|
||||
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
|
||||
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
|
||||
public String currentFinancialYear() {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
/** 1 April to 31 March for a "YYYY-YY" label. */
|
||||
public LocalDate[] financialYearRange(final String financialYear) {
|
||||
try {
|
||||
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||
} catch (RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
|
||||
"financialYear must look like 2026-27. Received: " + financialYear);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> loadGlByCode(final String glCode) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||
}
|
||||
|
||||
private String resolveCurrency(final Long glAccountId) {
|
||||
try {
|
||||
final String code = jdbcTemplate.queryForObject(
|
||||
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||
glAccountId);
|
||||
return code == null ? "INR" : code;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return "INR";
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
|
||||
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
|
||||
now.atOffset(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+5
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMakerCheckerEnabled() {
|
||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||
|
||||
+27
-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.filters.CallerIpTrackingFilter;
|
||||
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
||||
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
|
||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
||||
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
||||
@@ -163,6 +164,24 @@ public class SecurityConfig {
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||
// Client account summary, third-party callers — a partner integration calls this
|
||||
// with an X-Api-Key header instead of a Fineract username/password (issued via
|
||||
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
||||
// outer gate stays open for this one narrow GET path, and
|
||||
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
||||
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||
// it just fails inside the handler instead of at this filter.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
|
||||
// Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above
|
||||
// for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary
|
||||
// session when no key is sent, same as ClientAllAccountsApiResource does; permitAll
|
||||
// here only opens Spring Security's outer gate for the key path, it does not skip
|
||||
// the handler's own check). The PUT has no session fallback inside the handler at
|
||||
// all, even for a logged-in super user — see TenantMenuAccessApiResource#save.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll()
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll()
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll()
|
||||
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||
// no Fineract auth required.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||
@@ -408,7 +427,10 @@ public class SecurityConfig {
|
||||
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
||||
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
||||
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
||||
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
|
||||
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
|
||||
// Refuses the handful of accounting features switched off for this installation.
|
||||
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
|
||||
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
|
||||
|
||||
if (loanCOBFilterHelper != null) {
|
||||
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
||||
@@ -445,6 +467,10 @@ public class SecurityConfig {
|
||||
return new RequestResponseFilter();
|
||||
}
|
||||
|
||||
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
|
||||
return new DisabledFeatureApiFilter();
|
||||
}
|
||||
|
||||
public LoanCOBApiFilter loanCOBApiFilter() {
|
||||
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\"}");
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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.databaseexport.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/databaseexport")
|
||||
@Component
|
||||
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseExportApiResource {
|
||||
|
||||
private final DatabaseExportService service;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List export requests, optionally filtered by status")
|
||||
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
|
||||
return this.service.retrieveAll(status);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{requestId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a single export request")
|
||||
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
|
||||
return this.service.retrieveOne(requestId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Raise a request for a full database export")
|
||||
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
|
||||
final Long id = this.service.requestExport(reason);
|
||||
return Map.of("resourceId", id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/approve")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve an export request (admin only, never the requester)")
|
||||
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
|
||||
this.service.approve(requestId);
|
||||
return Map.of("resourceId", requestId, "status", "APPROVED");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/reject")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reject an export request with a reason")
|
||||
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
|
||||
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
|
||||
? json.get("rejectionReason").getAsString()
|
||||
: null;
|
||||
this.service.reject(requestId, reason);
|
||||
return Map.of("resourceId", requestId, "status", "REJECTED");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{requestId}/download")
|
||||
@Produces({ "application/zip" })
|
||||
@Operation(summary = "Download the approved export as a ZIP of CSV files")
|
||||
public Response download(@PathParam("requestId") final Long requestId) {
|
||||
final byte[] archive = this.service.generateExport(requestId);
|
||||
return Response.ok(archive)
|
||||
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
|
||||
.header("Content-Length", archive.length)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.databaseexport.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
|
||||
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_database_export_request")
|
||||
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
|
||||
|
||||
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
|
||||
public static final String STATUS_APPROVED = "APPROVED";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
/** Approved and the dump has actually been downloaded at least once. */
|
||||
public static final String STATUS_GENERATED = "GENERATED";
|
||||
|
||||
@Column(name = "status", length = 30, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "reason", nullable = false)
|
||||
private String reason;
|
||||
|
||||
@Column(name = "requested_by", nullable = false)
|
||||
private Long requestedBy;
|
||||
|
||||
@Column(name = "requested_by_username", length = 100)
|
||||
private String requestedByUsername;
|
||||
|
||||
@Column(name = "requested_on", nullable = false)
|
||||
private LocalDateTime requestedOn;
|
||||
|
||||
@Column(name = "approved_by")
|
||||
private Long approvedBy;
|
||||
|
||||
@Column(name = "approved_by_username", length = 100)
|
||||
private String approvedByUsername;
|
||||
|
||||
@Column(name = "approved_on")
|
||||
private LocalDateTime approvedOn;
|
||||
|
||||
@Column(name = "rejection_reason")
|
||||
private String rejectionReason;
|
||||
|
||||
@Column(name = "generated_on")
|
||||
private LocalDateTime generatedOn;
|
||||
|
||||
@Column(name = "generated_by")
|
||||
private Long generatedBy;
|
||||
|
||||
@Column(name = "file_name", length = 255)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "file_size_bytes")
|
||||
private Long fileSizeBytes;
|
||||
|
||||
@Column(name = "row_count")
|
||||
private Long rowCount;
|
||||
|
||||
protected DatabaseExportRequest() {
|
||||
// Default constructor for JPA
|
||||
}
|
||||
|
||||
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||
final LocalDateTime requestedOn) {
|
||||
this.status = STATUS_PENDING_APPROVAL;
|
||||
this.reason = reason;
|
||||
this.requestedBy = requestedBy;
|
||||
this.requestedByUsername = requestedByUsername;
|
||||
this.requestedOn = requestedOn;
|
||||
}
|
||||
|
||||
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||
final LocalDateTime requestedOn) {
|
||||
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public Long getRequestedBy() {
|
||||
return this.requestedBy;
|
||||
}
|
||||
|
||||
public boolean isPendingApproval() {
|
||||
return STATUS_PENDING_APPROVAL.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
|
||||
* second copy has to be asked for and approved again — so every copy of the database that exists outside
|
||||
* the system can be traced back to an approval that was granted for it specifically.
|
||||
*/
|
||||
public boolean isDownloadable() {
|
||||
return STATUS_APPROVED.equals(this.status);
|
||||
}
|
||||
|
||||
public boolean isAlreadyDownloaded() {
|
||||
return STATUS_GENERATED.equals(this.status);
|
||||
}
|
||||
|
||||
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
|
||||
this.status = STATUS_APPROVED;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedByUsername = approvedByUsername;
|
||||
this.approvedOn = approvedOn;
|
||||
}
|
||||
|
||||
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
|
||||
final String rejectionReason) {
|
||||
this.status = STATUS_REJECTED;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedByUsername = approvedByUsername;
|
||||
this.approvedOn = approvedOn;
|
||||
this.rejectionReason = rejectionReason;
|
||||
}
|
||||
|
||||
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
|
||||
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
|
||||
final Long fileSizeBytes, final Long rowCount) {
|
||||
this.status = STATUS_GENERATED;
|
||||
this.generatedBy = generatedBy;
|
||||
this.generatedOn = generatedOn;
|
||||
this.fileName = fileName;
|
||||
this.fileSizeBytes = fileSizeBytes;
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.databaseexport.domain;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
|
||||
|
||||
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
|
||||
|
||||
long countByRequestedByAndStatus(Long requestedBy, String status);
|
||||
|
||||
/**
|
||||
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
|
||||
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
|
||||
*/
|
||||
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
|
||||
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.databaseexport.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Requesting, approving and generating a full export of the tenant database. */
|
||||
public interface DatabaseExportService {
|
||||
|
||||
Long requestExport(String reason);
|
||||
|
||||
List<Map<String, Object>> retrieveAll(String status);
|
||||
|
||||
Map<String, Object> retrieveOne(Long requestId);
|
||||
|
||||
void approve(Long requestId);
|
||||
|
||||
void reject(Long requestId, String rejectionReason);
|
||||
|
||||
/**
|
||||
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
|
||||
* admin has already approved.
|
||||
*/
|
||||
byte[] generateExport(Long requestId);
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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.databaseexport.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
|
||||
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseExportServiceImpl implements DatabaseExportService {
|
||||
|
||||
private static final String PERMISSION = "DATABASEEXPORT";
|
||||
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
|
||||
private static final int MAX_ROWS_PER_TABLE = 200_000;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DatabaseExportRequestRepository repository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public Long requestExport(final String reason) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + PERMISSION);
|
||||
|
||||
if (reason == null || reason.trim().length() < 10) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
|
||||
"A reason of at least 10 characters is required for a database export request.");
|
||||
}
|
||||
|
||||
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
|
||||
// approver to rubber-stamp in bulk.
|
||||
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
|
||||
"You already have a database export request awaiting approval.");
|
||||
}
|
||||
|
||||
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
|
||||
LocalDateTime.now());
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
|
||||
return request.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||
|
||||
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
|
||||
// client `requestedbyusername` and leave every camelCase field on the screen empty.
|
||||
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
|
||||
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
|
||||
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
|
||||
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||
+ "FROM m_database_export_request ");
|
||||
|
||||
if (status != null && !status.trim().isEmpty()) {
|
||||
sql.append("WHERE status = ? ORDER BY id DESC");
|
||||
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
|
||||
}
|
||||
sql.append("ORDER BY id DESC");
|
||||
return this.jdbcTemplate.queryForList(sql.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> retrieveOne(final Long requestId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
|
||||
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
|
||||
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||
+ "FROM m_database_export_request WHERE id = ?",
|
||||
requestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void approve(final Long requestId) {
|
||||
final AppUser approver = this.context.authenticatedUser();
|
||||
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||
|
||||
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||
validatePending(request);
|
||||
validateNotSelfApproval(request, approver);
|
||||
|
||||
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void reject(final Long requestId, final String rejectionReason) {
|
||||
final AppUser approver = this.context.authenticatedUser();
|
||||
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||
|
||||
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||
validatePending(request);
|
||||
|
||||
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
|
||||
"A reason is required when rejecting a database export request.");
|
||||
}
|
||||
|
||||
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
|
||||
this.repository.saveAndFlush(request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public byte[] generateExport(final Long requestId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
|
||||
|
||||
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
|
||||
// calls must not both find it unspent.
|
||||
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
|
||||
"Export request not found."));
|
||||
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
|
||||
// being turned away with a message about approval that no longer describes their situation.
|
||||
if (request.isAlreadyDownloaded()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
|
||||
"This export has already been downloaded. Raise a new request to take another copy.");
|
||||
}
|
||||
if (!request.isDownloadable()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
|
||||
"This export request has not been approved. Current status: " + request.getStatus() + ".");
|
||||
}
|
||||
validateIsRequester(request, user);
|
||||
|
||||
final List<String> tables = listTenantTables();
|
||||
long totalRows = 0;
|
||||
|
||||
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
|
||||
for (final String table : tables) {
|
||||
try {
|
||||
totalRows += writeTableAsCsv(zip, table);
|
||||
} catch (final Exception e) {
|
||||
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
|
||||
log.error("Database export: skipping table {} — {}", table, e.getMessage());
|
||||
}
|
||||
}
|
||||
writeManifest(zip, request, tables, totalRows, user);
|
||||
} catch (final IOException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
|
||||
"Failed to generate the database export: " + e.getMessage());
|
||||
}
|
||||
|
||||
final byte[] archive = buffer.toByteArray();
|
||||
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
|
||||
+ ".zip";
|
||||
|
||||
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
|
||||
user.getId(), tables.size(), totalRows, archive.length);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private List<String> listTenantTables() {
|
||||
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
|
||||
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
|
||||
}
|
||||
|
||||
/** Writes one table as a CSV entry and returns how many rows it contained. */
|
||||
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
|
||||
// The table name comes from information_schema, never from user input, so it cannot carry injection.
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
|
||||
|
||||
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
|
||||
if (rows.isEmpty()) {
|
||||
// Still emit the header so an empty table is visibly empty rather than missing.
|
||||
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
|
||||
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
|
||||
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||
zip.write('\n');
|
||||
zip.closeEntry();
|
||||
return 0;
|
||||
}
|
||||
|
||||
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
|
||||
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||
zip.write('\n');
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final StringBuilder line = new StringBuilder();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
if (i > 0) {
|
||||
line.append(',');
|
||||
}
|
||||
line.append(csvEscape(row.get(columns.get(i))));
|
||||
}
|
||||
line.append('\n');
|
||||
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
zip.closeEntry();
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
|
||||
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
|
||||
final long totalRows, final AppUser downloadedBy) throws IOException {
|
||||
final Map<String, Object> details = retrieveOne(request.getId());
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest.append("CBSNexora — Full Database Export\n");
|
||||
manifest.append("=================================\n\n");
|
||||
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
|
||||
manifest.append("Handle under your institution's data protection policy.\n\n");
|
||||
manifest.append("Request ID : ").append(request.getId()).append('\n');
|
||||
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
|
||||
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
|
||||
manifest.append("Reason : ").append(details.get("reason")).append('\n');
|
||||
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
|
||||
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
|
||||
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
|
||||
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
|
||||
manifest.append("Tables exported : ").append(tables.size()).append('\n');
|
||||
manifest.append("Rows exported : ").append(totalRows).append('\n');
|
||||
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
|
||||
|
||||
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
|
||||
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
|
||||
private String csvEscape(final Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
final String text = String.valueOf(value);
|
||||
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
|
||||
return '"' + text.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
|
||||
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
|
||||
"error.msg.databaseexport.not.found", "Export request not found."));
|
||||
}
|
||||
|
||||
private void validatePending(final DatabaseExportRequest request) {
|
||||
if (!request.isPendingApproval()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
|
||||
"This request has already been actioned. Current status: " + request.getStatus() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
|
||||
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
|
||||
* approver download would collapse the two-person control back into one.
|
||||
*/
|
||||
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
|
||||
if (!user.getId().equals(request.getRequestedBy())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
|
||||
"Only the user who raised this export request can download the archive.");
|
||||
}
|
||||
}
|
||||
|
||||
/** The entire control is that two different people are involved, so this is the check that enforces it. */
|
||||
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
|
||||
if (approver.getId().equals(request.getRequestedBy())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
|
||||
"A database export cannot be approved by the same user who requested it.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
|
||||
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
||||
.retrieveAllJobs(fineractProperties.getNodeId());
|
||||
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
||||
// Release any currently_running flag left over from before this boot.
|
||||
//
|
||||
// The flag is set in the scheduler's veto check before a job's body runs and cleared
|
||||
// by the Quartz listener when it returns. Two things leave it set with nothing
|
||||
// running: a JVM that stopped mid-run, and a job started through its own API rather
|
||||
// than through Quartz — no listener is involved there, so nothing ever clears it.
|
||||
//
|
||||
// Once set, the veto silently drops every later trigger: no run-history row, no
|
||||
// error, nothing in the log. The job reads as though it ran and did nothing, and one
|
||||
// dead run mutes it for good. That is how a night of unpaid savings interest goes
|
||||
// unnoticed.
|
||||
//
|
||||
// This loop is the platform coming up, so by definition none of these jobs is
|
||||
// running: whatever the flag says, it is stale. scheduleJob() below already clears
|
||||
// it on the paths it reaches, but not when it throws before that line, and the
|
||||
// saveOrUpdate() after this loop persists whatever we set here either way.
|
||||
jobDetails.setCurrentlyRunning(false);
|
||||
try {
|
||||
jobRegisterService.scheduleJob(jobDetails);
|
||||
} catch (JobIsNotFoundOrNotEnabledException e) {
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.apiclient;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
||||
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
|
||||
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
||||
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
|
||||
* key in the first place is not something to hand to just anyone.
|
||||
*/
|
||||
@Path("/v1/apiclients")
|
||||
@Component
|
||||
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientAdminApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String create(final String body) {
|
||||
final AppUser user = requireSuperUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
||||
|
||||
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list() {
|
||||
requireSuperUser();
|
||||
final List<Map<String, Object>> clients = credentialService.listClients();
|
||||
return gson.toJson(clients);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String deactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, false);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", false));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reactivate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String reactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, true);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", true));
|
||||
}
|
||||
|
||||
private AppUser requireSuperUser() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new WebApplicationException(Response.status(403)
|
||||
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
|
||||
.build());
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+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.infrastructure.security.apiclient;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
|
||||
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
|
||||
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
|
||||
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
|
||||
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
|
||||
*
|
||||
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
|
||||
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
|
||||
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
|
||||
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
|
||||
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
|
||||
* for, so one minted key must keep working against every tenant that exists now AND every one
|
||||
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
|
||||
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
|
||||
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
|
||||
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
|
||||
* the right bank's database) -- this class only ever touches the one central table.
|
||||
*
|
||||
* Security properties, deliberately:
|
||||
* <ul>
|
||||
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
|
||||
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
|
||||
* never stores a user's plaintext password.</li>
|
||||
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
|
||||
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
|
||||
* deactivate the old.</li>
|
||||
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
|
||||
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
|
||||
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
|
||||
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
|
||||
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
|
||||
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientCredentialService {
|
||||
|
||||
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
|
||||
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
|
||||
// session token, appropriate for a credential that never expires on its own.
|
||||
private static final int KEY_ENTROPY_BYTES = 32;
|
||||
|
||||
@Qualifier("hikariTenantDataSource")
|
||||
private final DataSource tenantStoreDataSource;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
|
||||
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
|
||||
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
|
||||
private JdbcTemplate tenantStoreJdbc;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
|
||||
* display name (for the caller to log/attribute the request to) and records this call -- including
|
||||
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
|
||||
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
|
||||
*/
|
||||
public String authenticate(final String rawApiKey, final String callerIp) {
|
||||
if (StringUtils.isBlank(rawApiKey)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String hash = sha256Hex(rawApiKey);
|
||||
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
|
||||
""", hash);
|
||||
if (rows.isEmpty()) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
if (!Boolean.TRUE.equals(row.get("is_active"))) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String allowedIps = (String) row.get("allowed_ips");
|
||||
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
final Long id = ((Number) row.get("id")).longValue();
|
||||
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
|
||||
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
|
||||
|
||||
return (String) row.get("client_name");
|
||||
}
|
||||
|
||||
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
|
||||
if (StringUtils.isBlank(callerIp)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
|
||||
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
|
||||
* authenticates against every tenant, regardless of which one the admin minting it happened to be
|
||||
* logged into.
|
||||
*/
|
||||
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
|
||||
if (StringUtils.isBlank(clientName)) {
|
||||
throw badRequest("clientName is required.");
|
||||
}
|
||||
final String rawKey = generateRawKey();
|
||||
final String hash = sha256Hex(rawKey);
|
||||
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
|
||||
|
||||
final Long id = tenantStoreJdbc.queryForObject("""
|
||||
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
|
||||
currentTenantId());
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", id);
|
||||
result.put("clientName", clientName);
|
||||
result.put("apiKey", rawKey);
|
||||
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listClients() {
|
||||
return tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
|
||||
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
|
||||
FROM m_api_client ORDER BY created_date DESC
|
||||
""");
|
||||
}
|
||||
|
||||
public void setActive(final Long id, final boolean active) {
|
||||
final int updated = tenantStoreJdbc.update(
|
||||
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
|
||||
active ? null : java.sql.Timestamp.from(Instant.now()), id);
|
||||
if (updated == 0) {
|
||||
throw notFound("No API client with id " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentTenantId() {
|
||||
try {
|
||||
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
|
||||
} catch (final RuntimeException e) {
|
||||
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
|
||||
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
|
||||
// should be unreachable in practice.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRawKey() {
|
||||
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
|
||||
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String sha256Hex(final String raw) {
|
||||
try {
|
||||
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
|
||||
for (final byte b : hashBytes) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
|
||||
// this can only mean a broken JVM install, not a recoverable runtime condition.
|
||||
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationException unauthorized() {
|
||||
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
|
||||
// put on the request, not something this check reveals to them -- and it turns "why did my
|
||||
// key fail" from a guess into something checkable: was this call even reaching the bank the
|
||||
// caller thought it was?
|
||||
final String tenantId = currentTenantId();
|
||||
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
|
||||
return jsonError(401, message);
|
||||
}
|
||||
|
||||
private WebApplicationException badRequest(final String message) {
|
||||
return jsonError(400, message);
|
||||
}
|
||||
|
||||
private WebApplicationException notFound(final String message) {
|
||||
return jsonError(404, message);
|
||||
}
|
||||
|
||||
private WebApplicationException jsonError(final int status, final String message) {
|
||||
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
|
||||
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+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 jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
@@ -200,6 +201,20 @@ public class OfficesApiResource {
|
||||
return toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{officeId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||
.deleteOffice(officeId) //
|
||||
.build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/external-id/{externalId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+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 deleteOffice(Long officeId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult officeTransaction(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.Caching;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||
private final OfficeTransactionRepository officeTransactionRepository;
|
||||
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
||||
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
|
||||
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
|
||||
// repository for every one of them just to count rows would be a lot of new wiring for a check
|
||||
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
|
||||
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
|
||||
// long tail that is left is caught by the try/catch around the delete itself.
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an office outright -- distinct from every other office command, which only ever changes
|
||||
* status or fields. There is no "closed" state for an office the way there is for a client or a
|
||||
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
|
||||
* failed with a generic error no matter how empty the office was; the endpoint it called never
|
||||
* existed.
|
||||
*
|
||||
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
|
||||
* structural block), then staff, clients and groups (the day-to-day reason an office still has
|
||||
* anything in it), then any AppUser still homed there. Everything else that references m_office --
|
||||
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
|
||||
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
|
||||
* violation on the delete itself is caught below and turned into one clear message instead. An
|
||||
* office with real banking history behind it is not something this should make easy to remove either
|
||||
* way, checked up front or not.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
@Caching(evict = {
|
||||
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
|
||||
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
|
||||
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
|
||||
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
|
||||
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
|
||||
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
|
||||
"Move or remove its sub-offices before deleting this office.");
|
||||
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
|
||||
"Reassign its staff to another office before deleting this office.");
|
||||
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
|
||||
"Transfer its clients to another office before deleting this office.");
|
||||
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
|
||||
"Transfer its groups to another office before deleting this office.");
|
||||
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
|
||||
"Reassign the users homed at this office before deleting it.");
|
||||
|
||||
try {
|
||||
this.officeRepositoryWrapper.deleteAndFlush(office);
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||
} catch (final PersistenceException dve) {
|
||||
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(officeId) //
|
||||
.withOfficeId(officeId) //
|
||||
.build();
|
||||
}
|
||||
|
||||
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
|
||||
final String plural, final String remedy) {
|
||||
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
|
||||
officeId);
|
||||
if (count != null && count > 0) {
|
||||
final String noun = count == 1 ? singular : plural;
|
||||
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
|
||||
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
||||
|
||||
+2
-2
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
|
||||
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
||||
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
|
||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
|
||||
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
||||
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
||||
applicationCurrencyRepository);
|
||||
applicationCurrencyRepository, jdbcTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
||||
accountsSummary.append("sa.deposit_type_enum as depositType ");
|
||||
accountsSummary.append("sa.deposit_type_enum as depositType, ");
|
||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
||||
// FD into one entry rather than listing every term as a separate account.
|
||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
||||
accountsSummary.append("from m_savings_account sa ");
|
||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
||||
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
||||
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
||||
|
||||
// Null for a savings/current account and for an FD opened before this column existed;
|
||||
// callers fall back to accountNo, so an FD that has never been renewed still reads as
|
||||
// its own FD number.
|
||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
||||
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||
lastActiveTransactionDate);
|
||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
|
||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||
|
||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
|
||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.accountregister.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Account register for one date: which accounts were opened and which were closed that day, with
|
||||
* each account's balance as it stood on that date alongside what it holds now.
|
||||
*
|
||||
* The dated balance is read from the running balance stamped on the last transaction up to that
|
||||
* date, rather than recomputed from the account's current figure — a current balance tells you
|
||||
* nothing about what the account held three weeks ago.
|
||||
*/
|
||||
@Path("/v1/accountregister")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class AccountRegisterApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final LocalDate asOn;
|
||||
if (dateParam != null && !dateParam.isBlank()) {
|
||||
try {
|
||||
asOn = LocalDate.parse(dateParam);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
|
||||
"Date must be in yyyy-MM-dd format.");
|
||||
}
|
||||
} else {
|
||||
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
}
|
||||
|
||||
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
|
||||
rows.addAll(depositAccounts(asOn, want));
|
||||
}
|
||||
if (matches(want, "SHARE")) {
|
||||
rows.addAll(shareAccounts(asOn));
|
||||
}
|
||||
|
||||
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
|
||||
// so the totals can never disagree with the list they summarise.
|
||||
int opened = 0;
|
||||
int closed = 0;
|
||||
BigDecimal balanceOnDate = BigDecimal.ZERO;
|
||||
BigDecimal currentBalance = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> r : rows) {
|
||||
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
|
||||
opened++;
|
||||
}
|
||||
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
|
||||
closed++;
|
||||
}
|
||||
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
|
||||
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("date", asOn.toString());
|
||||
out.put("section", want);
|
||||
out.put("accounts", rows);
|
||||
out.put("count", rows.size());
|
||||
out.put("openedCount", opened);
|
||||
out.put("closedCount", closed);
|
||||
out.put("totalBalanceOnDate", balanceOnDate);
|
||||
out.put("totalCurrentBalance", currentBalance);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
private static boolean matches(final String want, final String section) {
|
||||
return "ALL".equals(want) || want.equals(section);
|
||||
}
|
||||
|
||||
private static BigDecimal dec(final Object o) {
|
||||
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
|
||||
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
|
||||
*
|
||||
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
|
||||
* them would make a register of a past day describe a present one.
|
||||
*/
|
||||
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
|
||||
final Date date = Date.valueOf(asOn);
|
||||
|
||||
final StringBuilder typeFilter = new StringBuilder();
|
||||
if (!"ALL".equals(want)) {
|
||||
final int type = switch (want) {
|
||||
case "SAVINGS" -> 100;
|
||||
case "FD" -> 200;
|
||||
case "RD" -> 300;
|
||||
default -> 0;
|
||||
};
|
||||
if (type > 0) {
|
||||
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
|
||||
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
|
||||
sa.account_balance_derived,
|
||||
c.display_name AS client_name,
|
||||
p.name AS product_name,
|
||||
(SELECT t.running_balance_derived
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = sa.id
|
||||
AND COALESCE(t.is_reversed, false) = false
|
||||
AND t.transaction_date <= ?
|
||||
ORDER BY t.transaction_date DESC, t.id DESC
|
||||
LIMIT 1) AS balance_on_date
|
||||
FROM m_savings_account sa
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_savings_product p ON p.id = sa.product_id
|
||||
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
|
||||
""" + typeFilter + """
|
||||
ORDER BY sa.deposit_type_enum, sa.account_no
|
||||
""", date, date);
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
final int depositType = num(r.get("deposit_type_enum"));
|
||||
m.put("accountId", r.get("id"));
|
||||
m.put("accountNo", r.get("account_no"));
|
||||
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
|
||||
m.put("clientName", r.get("client_name"));
|
||||
m.put("productName", r.get("product_name"));
|
||||
m.put("status", savingsStatus(num(r.get("status_enum"))));
|
||||
m.put("openedOn", str(r.get("activatedon_date")));
|
||||
m.put("closedOn", str(r.get("closedon_date")));
|
||||
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
|
||||
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
|
||||
// No transaction up to that date means the account had nothing in it yet, not that the
|
||||
// balance is unknown.
|
||||
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
|
||||
m.put("currentBalance", r.get("account_balance_derived") == null
|
||||
? BigDecimal.ZERO : r.get("account_balance_derived"));
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
|
||||
* are individually dated, so the holding on a given date is the shares purchased up to that
|
||||
* date less those redeemed — which is a real dated figure, not the current one relabelled.
|
||||
*/
|
||||
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
|
||||
final Date date = Date.valueOf(asOn);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
|
||||
sa.total_approved_shares,
|
||||
c.display_name AS client_name,
|
||||
p.name AS product_name,
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)) AS share_value,
|
||||
-- Purchases less redemptions dated on or before the register's date. Charge
|
||||
-- rows (type 700) are excluded: an entry fee is not share capital. Only
|
||||
-- approved purchases count (status 300) — a rejected application leaves its
|
||||
-- row behind still flagged active, and counting it credited a member with
|
||||
-- shares they were never issued.
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)
|
||||
AND t.transaction_date <= ?) AS value_on_date,
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
|
||||
WHEN t.type_enum = 600 THEN -t.total_shares
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)
|
||||
AND t.transaction_date <= ?) AS shares_on_date
|
||||
FROM m_share_account sa
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_share_product p ON p.id = sa.product_id
|
||||
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
|
||||
ORDER BY sa.account_no
|
||||
""", date, date, date);
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("accountId", r.get("id"));
|
||||
m.put("accountNo", r.get("account_no"));
|
||||
m.put("section", "SHARE");
|
||||
m.put("clientName", r.get("client_name"));
|
||||
m.put("productName", r.get("product_name"));
|
||||
m.put("status", shareStatus(num(r.get("status_enum"))));
|
||||
m.put("openedOn", str(r.get("activated_date")));
|
||||
m.put("closedOn", str(r.get("closed_date")));
|
||||
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
|
||||
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
|
||||
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
|
||||
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
|
||||
// Shares as at the date, not the approved figure — an account can hold fewer than were
|
||||
// approved once some have been redeemed.
|
||||
m.put("sharesHeld", r.get("shares_on_date"));
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
|
||||
return dbDate != null && asOn.toString().equals(dbDate.toString());
|
||||
}
|
||||
|
||||
private static String str(final Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
|
||||
private static int num(final Object o) {
|
||||
return o instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
|
||||
private static String savingsStatus(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "SUBMITTED";
|
||||
case 200 -> "APPROVED";
|
||||
case 300 -> "ACTIVE";
|
||||
case 400 -> "WITHDRAWN";
|
||||
case 500 -> "REJECTED";
|
||||
case 600 -> "CLOSED";
|
||||
case 700 -> "TRANSFER IN PROGRESS";
|
||||
case 800 -> "TRANSFER ON HOLD";
|
||||
case 900 -> "PREMATURELY CLOSED";
|
||||
default -> "UNKNOWN";
|
||||
};
|
||||
}
|
||||
|
||||
private static String shareStatus(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "SUBMITTED";
|
||||
case 200 -> "APPROVED";
|
||||
case 300 -> "ACTIVE";
|
||||
case 400 -> "WITHDRAWN";
|
||||
case 500 -> "REJECTED";
|
||||
case 600 -> "CLOSED";
|
||||
default -> "UNKNOWN";
|
||||
};
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -102,12 +102,54 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
String shareEntityType = "share";
|
||||
|
||||
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
|
||||
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
|
||||
String redeemresolutionnumber_paramname = "resolutionNumber";
|
||||
|
||||
String redeemmeetingdate_paramname = "meetingDate";
|
||||
|
||||
/** Loan this share account is held as margin/security against — optional, set via the
|
||||
* account update command. When present, a redeem request is checked against the loan's
|
||||
* outstanding balance. */
|
||||
String linkedloanid_paramname = "linkedLoanId";
|
||||
|
||||
/**
|
||||
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
|
||||
*
|
||||
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
|
||||
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
|
||||
* differ only in how it got there: CASH means the cashier already took it at the counter
|
||||
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
|
||||
*/
|
||||
String fundingsource_paramname = "fundingSource";
|
||||
|
||||
String FUNDING_SOURCE_CASH = "CASH";
|
||||
|
||||
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
|
||||
|
||||
/**
|
||||
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
|
||||
*
|
||||
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
|
||||
* see which receipt the money they are releasing came in on.
|
||||
*/
|
||||
String advancecollectionid_paramname = "advanceCollectionId";
|
||||
|
||||
/**
|
||||
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
|
||||
*
|
||||
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
|
||||
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
|
||||
* rest, and their combined total is what has to cover the shares.
|
||||
*/
|
||||
String additionalcollectionids_paramname = "additionalCollectionIds";
|
||||
|
||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
numberofshares_paramname, purchasedprice_paramname)));
|
||||
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user