Compare commits

..
Author SHA1 Message Date
tarunmalviya 37c961f713 DDS: Agent settlement (Agent Tran), Close Party account, reports (Supplementary-DDS/Master Data Export/Close Register), Party-Savings link, product category field
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-25 10:33:37 +05:30
509 changed files with 16101 additions and 46433 deletions
-68
View File
@@ -1,68 +0,0 @@
-- 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,6 +127,14 @@ 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,
@@ -160,5 +168,6 @@ 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));
+4
View File
@@ -17,6 +17,7 @@ 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;
@@ -35,6 +36,7 @@ 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);
@@ -53,6 +55,7 @@ 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;
@@ -71,5 +74,6 @@ 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);
@@ -45,22 +45,6 @@ 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();
@@ -75,8 +59,11 @@ 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(MAPPABLE_FINANCIAL_ACTIVITIES);
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());
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
@@ -99,8 +86,10 @@ 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(MAPPABLE_FINANCIAL_ACTIVITIES);
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());
}
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
@@ -37,7 +37,6 @@ 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() {
@@ -59,8 +58,6 @@ 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();
}
@@ -82,8 +79,6 @@ 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();
}
@@ -181,14 +181,10 @@ 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 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);
@@ -214,29 +210,20 @@ 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();
}
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();
}
final Long incomeFromInterest = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
.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();
}
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
.integerGreaterThanZero();
}
}
@@ -426,8 +426,6 @@ 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);
@@ -472,8 +470,6 @@ 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);
@@ -106,20 +106,6 @@ 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,
@@ -262,8 +248,6 @@ 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
@@ -324,8 +308,6 @@ 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:
@@ -269,15 +269,7 @@ public final class AccountingConstants {
OVERDRAFT_PORTFOLIO_CONTROL(11), //
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
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); //
ESCHEAT_LIABILITY(14); //
private final Integer value;
@@ -325,9 +317,7 @@ public final class AccountingConstants {
FEES_RECEIVABLE(15), //
PENALTIES_RECEIVABLE(16), //
INTEREST_PAYABLE(17), //
INTEREST_RECEIVABLE(18), //
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
ROUNDING_ADJUSTMENT(26);
INTEREST_RECEIVABLE(18);
private final Integer value;
@@ -382,8 +372,7 @@ public final class AccountingConstants {
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
FEES_RECEIVABLE("feesReceivableAccountId"), //
INTEREST_PAYABLE("interestPayableAccountId"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
INTEREST_PAYABLE("interestPayableAccountId");
private final String value;
@@ -421,8 +410,7 @@ public final class AccountingConstants {
FEES_RECEIVABLE("feeReceivableAccount"), //
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
INTEREST_PAYABLE("interestPayableAccount"), //
INTEREST_RECEIVABLE("interestReceivableAccount"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
INTEREST_RECEIVABLE("interestReceivableAccount"); //
private final String value;
@@ -449,34 +437,8 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
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); //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
private final Integer value;
private final String code;
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,25 +151,6 @@ 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;
@@ -664,11 +664,75 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteOffice(final Long officeId) {
this.actionName = "DELETE";
this.entityName = "OFFICE";
this.entityId = officeId;
this.href = "/offices/" + officeId;
public CommandWrapperBuilder createAgent() {
this.actionName = "CREATE";
this.entityName = "AGENT";
this.entityId = null;
this.href = "/dds/agents/template";
return this;
}
public CommandWrapperBuilder updateAgent(final Long agentId) {
this.actionName = "UPDATE";
this.entityName = "AGENT";
this.entityId = agentId;
this.href = "/dds/agents/" + agentId;
return this;
}
public CommandWrapperBuilder createParty() {
this.actionName = "CREATE";
this.entityName = "PARTY";
this.entityId = null;
this.href = "/dds/parties/template";
return this;
}
public CommandWrapperBuilder updateParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId;
return this;
}
public CommandWrapperBuilder closeParty(final Long partyId) {
this.actionName = "CLOSE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId + "?command=close";
return this;
}
public CommandWrapperBuilder createPartyTransaction() {
this.actionName = "CREATE";
this.entityName = "PARTYTRANSACTION";
this.entityId = null;
this.href = "/dds/partytransactions/template";
return this;
}
public CommandWrapperBuilder createCommissionSlabs() {
this.actionName = "CREATE";
this.entityName = "COMMISSIONSLAB";
this.entityId = null;
this.href = "/dds/commissionslabs/template";
return this;
}
public CommandWrapperBuilder generateDdsBatchRun() {
this.actionName = "GENERATE";
this.entityName = "DDSBATCH";
this.entityId = null;
this.href = "/dds/batchruns/template";
return this;
}
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
this.actionName = "POST";
this.entityName = "DDSBATCH";
this.entityId = batchRunId;
this.href = "/dds/batchruns/" + batchRunId;
return this;
}
@@ -2219,14 +2283,6 @@ 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";
@@ -2913,15 +2969,6 @@ 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";
@@ -3128,14 +3175,6 @@ 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";
@@ -26,8 +26,6 @@ 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
@@ -130,19 +130,6 @@ 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 + ".";
@@ -39,10 +39,7 @@ public enum JobName {
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"), //
@@ -56,7 +53,6 @@ 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"), //
@@ -0,0 +1,148 @@
/**
* 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.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Getter;
/**
* Immutable data object representing a DDS agent, including its collection-limit history and
* guarantors. Carries the collection/commission/security-account fields merged in from SubAgent
* (2026-08-24) - Agent now does the daily collection work directly.
*/
@Getter
public final class AgentData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final String agentCode;
private final String agentNameId;
private final String agentAddress1;
private final String agentAddress2;
private final String agentCity;
private final String agentPinCode;
private final String agentTelephone;
private final String agentSavingAccountType;
private final String agentSavingAccountNo;
private final LocalDate agentOpeningDate;
private final LocalDate agentBirthDate;
private final BigDecimal agentCurrentBalance;
private final BigDecimal agentOpeningBalance;
private final String homeBranch;
private final String savingBranch;
private final Boolean tdsApplicable;
private final BigDecimal tdsPercent;
private final BigDecimal currentTds;
private final BigDecimal tdsAmount;
private final String transferBranch;
private final String transferAmountAccountType;
private final String transferAccountNo;
private final Boolean commissionCalculation;
private final BigDecimal limit;
private final BigDecimal machineRent;
private final String agentSecurityBranch;
private final String agentSecurityAccountType;
private final String agentSecurityAccountNo;
private final BigDecimal commission;
private final BigDecimal otherCommission;
private final BigDecimal agentDeposit;
private final BigDecimal festivalAdvance;
private final BigDecimal otherDeduction;
private final Collection<AgentLimitData> limits;
private final Collection<AgentGuarantorData> guarantors;
public static AgentData instance(final Long id, final Long clientId, final String clientName, final String agentCode,
final String agentNameId, final String agentAddress1, final String agentAddress2, final String agentCity,
final String agentPinCode, final String agentTelephone, final String agentSavingAccountType,
final String agentSavingAccountNo, final LocalDate agentOpeningDate, final LocalDate agentBirthDate,
final BigDecimal agentCurrentBalance, final BigDecimal agentOpeningBalance, final String homeBranch,
final String savingBranch, final Boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal currentTds,
final BigDecimal tdsAmount, final String transferBranch, final String transferAmountAccountType,
final String transferAccountNo, final Boolean commissionCalculation, final BigDecimal limit, final BigDecimal machineRent,
final String agentSecurityBranch, final String agentSecurityAccountType, final String agentSecurityAccountNo,
final BigDecimal commission, final BigDecimal otherCommission, final BigDecimal agentDeposit,
final BigDecimal festivalAdvance, final BigDecimal otherDeduction, final Collection<AgentLimitData> limits,
final Collection<AgentGuarantorData> guarantors) {
return new AgentData(id, clientId, clientName, agentCode, agentNameId, agentAddress1, agentAddress2, agentCity, agentPinCode,
agentTelephone, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentCurrentBalance,
agentOpeningBalance, homeBranch, savingBranch, tdsApplicable, tdsPercent, currentTds, tdsAmount, transferBranch,
transferAmountAccountType, transferAccountNo, commissionCalculation, limit, machineRent, agentSecurityBranch,
agentSecurityAccountType, agentSecurityAccountNo, commission, otherCommission, agentDeposit, festivalAdvance,
otherDeduction, limits, guarantors);
}
private AgentData(final Long id, final Long clientId, final String clientName, final String agentCode, final String agentNameId,
final String agentAddress1, final String agentAddress2, final String agentCity, final String agentPinCode,
final String agentTelephone, final String agentSavingAccountType, final String agentSavingAccountNo,
final LocalDate agentOpeningDate, final LocalDate agentBirthDate, final BigDecimal agentCurrentBalance,
final BigDecimal agentOpeningBalance, final String homeBranch, final String savingBranch, final Boolean tdsApplicable,
final BigDecimal tdsPercent, final BigDecimal currentTds, final BigDecimal tdsAmount, final String transferBranch,
final String transferAmountAccountType, final String transferAccountNo, final Boolean commissionCalculation,
final BigDecimal limit, final BigDecimal machineRent, final String agentSecurityBranch,
final String agentSecurityAccountType, final String agentSecurityAccountNo, final BigDecimal commission,
final BigDecimal otherCommission, final BigDecimal agentDeposit, final BigDecimal festivalAdvance,
final BigDecimal otherDeduction, final Collection<AgentLimitData> limits, final Collection<AgentGuarantorData> guarantors) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.agentCode = agentCode;
this.agentNameId = agentNameId;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = agentAddress2;
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = agentTelephone;
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentCurrentBalance = agentCurrentBalance;
this.agentOpeningBalance = agentOpeningBalance;
this.homeBranch = homeBranch;
this.savingBranch = savingBranch;
this.tdsApplicable = tdsApplicable;
this.tdsPercent = tdsPercent;
this.currentTds = currentTds;
this.tdsAmount = tdsAmount;
this.transferBranch = transferBranch;
this.transferAmountAccountType = transferAmountAccountType;
this.transferAccountNo = transferAccountNo;
this.commissionCalculation = commissionCalculation;
this.limit = limit;
this.machineRent = machineRent;
this.agentSecurityBranch = agentSecurityBranch;
this.agentSecurityAccountType = agentSecurityAccountType;
this.agentSecurityAccountNo = agentSecurityAccountNo;
this.commission = commission;
this.otherCommission = otherCommission;
this.agentDeposit = agentDeposit;
this.festivalAdvance = festivalAdvance;
this.otherDeduction = otherDeduction;
this.limits = limits;
this.guarantors = guarantors;
}
}
@@ -0,0 +1,59 @@
/**
* 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.agent.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.Getter;
/**
* Immutable data object representing a single agent guarantor.
*/
@Getter
public final class AgentGuarantorData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String guarantorName;
private final String address1;
private final String address2;
private final String city;
private final String pinCode;
private final String telephone;
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
}
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
this.id = id;
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = address2;
this.city = city;
this.pinCode = pinCode;
this.telephone = telephone;
}
}
@@ -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.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class AgentLimitData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate fromDate;
private final LocalDate toDate;
private final BigDecimal limit;
public static AgentLimitData instance(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
return new AgentLimitData(id, fromDate, toDate, limit);
}
private AgentLimitData(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
this.id = id;
this.fromDate = fromDate;
this.toDate = toDate;
this.limit = limit;
}
}
@@ -0,0 +1,440 @@
/**
* 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.agent.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
* or more guarantors.
*/
@Getter
@Entity
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
public class Agent extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "agent_code", length = 20, nullable = false)
private String agentCode;
@Column(name = "agent_name", length = 100, nullable = false)
private String agentName;
@Column(name = "agent_address1", length = 200, nullable = false)
private String agentAddress1;
@Column(name = "agent_address2", length = 200)
private String agentAddress2;
@Column(name = "agent_city", length = 100, nullable = false)
private String agentCity;
@Column(name = "agent_pincode", length = 20, nullable = false)
private String agentPinCode;
@Column(name = "agent_telephone", length = 20)
private String agentTelephone;
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
private String agentSavingAccountType;
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
private String agentSavingAccountNo;
@Column(name = "agent_opening_date", nullable = false)
private LocalDate agentOpeningDate;
@Column(name = "agent_birth_date", nullable = false)
private LocalDate agentBirthDate;
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentCurrentBalance;
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentOpeningBalance;
// Fields below were merged in from SubAgent (2026-08-24) - the Agent itself now does the daily
// collection work directly; SubAgent no longer exists as a separate tier. Ag.Level / Upper Ag.Level
// / Collector (MLM) / CRPB Points / Level Update Date were dropped - they only ever meant anything
// for a multi-level hierarchy, which no longer exists here.
@Column(name = "home_branch", length = 100, nullable = false)
private String homeBranch;
@Column(name = "saving_branch", length = 100, nullable = false)
private String savingBranch;
@Column(name = "tds_applicable", nullable = false)
private boolean tdsApplicable;
@Column(name = "tds_percent", scale = 6, precision = 19)
private BigDecimal tdsPercent;
@Column(name = "current_tds", scale = 6, precision = 19, nullable = false)
private BigDecimal currentTds;
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal tdsAmount;
@Column(name = "transfer_branch", length = 100)
private String transferBranch;
@Column(name = "transfer_amount_account_type", length = 100)
private String transferAmountAccountType;
@Column(name = "transfer_account_no", length = 50)
private String transferAccountNo;
@Column(name = "collector_commission_calculation", nullable = false)
private boolean commissionCalculation;
@Column(name = "collector_limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
@Column(name = "machine_rent", scale = 6, precision = 19, nullable = false)
private BigDecimal machineRent;
@Column(name = "agent_security_branch", length = 100)
private String agentSecurityBranch;
@Column(name = "agent_security_account_type", length = 100)
private String agentSecurityAccountType;
@Column(name = "agent_security_account_no", length = 50)
private String agentSecurityAccountNo;
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
private BigDecimal commission;
@Column(name = "other_commission", scale = 6, precision = 19, nullable = false)
private BigDecimal otherCommission;
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
private BigDecimal agentDeposit;
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
private BigDecimal festivalAdvance;
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
private BigDecimal otherDeduction;
@Column(name = "last_commission_date")
private LocalDate lastCommissionDate;
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentGuarantor> guarantors = new ArrayList<>();
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentLimit> limits = new ArrayList<>();
protected Agent() {
//
}
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final JsonCommand command) {
this.client = client;
this.agentCode = agentCode;
this.agentName = agentName;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
this.agentCurrentBalance = this.agentOpeningBalance;
this.homeBranch = command.stringValueOfParameterNamed("homeBranch");
this.savingBranch = command.stringValueOfParameterNamed("savingBranch");
this.tdsApplicable = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
this.tdsPercent = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("tdsPercent");
this.currentTds = BigDecimal.ZERO;
this.tdsAmount = BigDecimal.ZERO;
this.transferBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferBranch"), null);
this.transferAmountAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAmountAccountType"), null);
this.transferAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAccountNo"), null);
this.commissionCalculation = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
this.limitAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("limit"));
this.machineRent = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("machineRent"));
this.agentSecurityBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityBranch"),
null);
this.agentSecurityAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountType"), null);
this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountNo"),
null);
this.commission = BigDecimal.ZERO;
this.otherCommission = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherCommission"));
this.agentDeposit = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentDeposit"));
this.festivalAdvance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("festivalAdvance"));
this.otherDeduction = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherDeduction"));
}
public static Agent fromJson(final Client client, final String agentCode, final JsonCommand command) {
final String agentName = command.stringValueOfParameterNamed("agentNameId");
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
final String agentCity = command.stringValueOfParameterNamed("agentCity");
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addGuarantor(final AgentGuarantor guarantor) {
guarantor.setAgent(this);
this.guarantors.add(guarantor);
}
public void resetGuarantors() {
this.guarantors.clear();
}
public void addLimit(final AgentLimit limit) {
limit.setAgent(this);
this.limits.add(limit);
}
public void resetLimits() {
this.limits.clear();
}
/**
* Adds a Commission Posting's amount to this agent's running commission total and stamps the date
* commission was last calculated through, so the next Commission Calculation batch only covers
* collections since then.
*/
public void postCommission(final BigDecimal amount, final LocalDate throughDate) {
this.commission = this.commission.add(amount);
this.lastCommissionDate = throughDate;
}
/**
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement - this is the
* figure an Agent's collection limit is enforced against.
*/
public BigDecimal credit(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
return this.agentCurrentBalance;
}
public BigDecimal settle(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
return this.agentCurrentBalance;
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public Long clientId() {
return this.client.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
final String newValue = command.stringValueOfParameterNamed("agentNameId");
actualChanges.put("agentNameId", newValue);
this.agentName = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
actualChanges.put("agentAddress1", newValue);
this.agentAddress1 = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
actualChanges.put("agentAddress2", newValue);
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
final String newValue = command.stringValueOfParameterNamed("agentCity");
actualChanges.put("agentCity", newValue);
this.agentCity = newValue;
}
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
actualChanges.put("agentPinCode", newValue);
this.agentPinCode = newValue;
}
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
actualChanges.put("agentTelephone", newValue);
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
actualChanges.put("agentSavingAccountType", newValue);
this.agentSavingAccountType = newValue;
}
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
actualChanges.put("agentSavingAccountNo", newValue);
this.agentSavingAccountNo = newValue;
}
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
}
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
}
stringChange(command, actualChanges, "homeBranch", this.homeBranch, v -> this.homeBranch = v);
stringChange(command, actualChanges, "savingBranch", this.savingBranch, v -> this.savingBranch = v);
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
actualChanges.put("tdsApplicable", newValue);
this.tdsApplicable = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("tdsPercent", this.tdsPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("tdsPercent");
actualChanges.put("tdsPercent", newValue);
this.tdsPercent = newValue;
}
stringChange(command, actualChanges, "transferBranch", this.transferBranch,
v -> this.transferBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAmountAccountType", this.transferAmountAccountType,
v -> this.transferAmountAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAccountNo", this.transferAccountNo,
v -> this.transferAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
actualChanges.put("commissionCalculation", newValue);
this.commissionCalculation = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("limit", this.limitAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("limit");
actualChanges.put("limit", newValue);
this.limitAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("machineRent", this.machineRent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("machineRent");
actualChanges.put("machineRent", newValue);
this.machineRent = newValue;
}
stringChange(command, actualChanges, "agentSecurityBranch", this.agentSecurityBranch,
v -> this.agentSecurityBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountType", this.agentSecurityAccountType,
v -> this.agentSecurityAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountNo", this.agentSecurityAccountNo,
v -> this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBigDecimalParameterNamed("otherCommission", this.otherCommission)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherCommission");
actualChanges.put("otherCommission", newValue);
this.otherCommission = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("agentDeposit", this.agentDeposit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentDeposit");
actualChanges.put("agentDeposit", newValue);
this.agentDeposit = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("festivalAdvance", this.festivalAdvance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("festivalAdvance");
actualChanges.put("festivalAdvance", newValue);
this.festivalAdvance = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("otherDeduction", this.otherDeduction)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherDeduction");
actualChanges.put("otherDeduction", newValue);
this.otherDeduction = newValue;
}
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
* no independent API of its own.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_guarantor")
public class AgentGuarantor extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "guarantor_name", length = 100, nullable = false)
private String guarantorName;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pin_code", length = 20, nullable = false)
private String pinCode;
@Column(name = "telephone", length = 20)
private String telephone;
protected AgentGuarantor() {
//
}
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
final String city, final String pinCode, final String telephone) {
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = StringUtils.defaultIfEmpty(address2, null);
this.city = city;
this.pinCode = pinCode;
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of an Agent's time-bound collection-limit history (From Date / To Date / Limit) - an
* Agent's authorized collection limit can change over time and every change is kept, not overwritten.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_limit")
public class AgentLimit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "from_date", nullable = false)
private LocalDate fromDate;
@Column(name = "to_date", nullable = false)
private LocalDate toDate;
@Column(name = "limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
protected AgentLimit() {
//
}
public AgentLimit(final LocalDate fromDate, final LocalDate toDate, final BigDecimal limitAmount) {
this.fromDate = fromDate;
this.toDate = toDate;
this.limitAmount = limitAmount;
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
*/
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
public AgentNotFoundException(final Long id) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
}
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
}
public AgentNotFoundException(final String agentCode) {
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
}
}
@@ -16,15 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.service;
package org.apache.fineract.organisation.commissionslab.data;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.useradministration.data.UserPendingRequestData;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
public interface UserPendingReadPlatformService {
@Getter
@Builder
public final class CommissionSlabData implements Serializable {
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
Integer offset, Integer limit);
@Serial
private static final long serialVersionUID = 1L;
UserPendingRequestData retrievePendingRequest(Long id);
private final Long id;
private final String slabType;
private final BigDecimal slabFrom;
private final BigDecimal slabTo;
private final BigDecimal commissionPercent;
}
@@ -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.organisation.commissionslab.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
* Append-only reference data, like the legacy screen it replaces - there is deliberately no update or
* delete API; a superseded slab range is retired by adding a new row, not editing history.
*/
@Getter
@Entity
@Table(name = "m_dds_commission_slab")
public class CommissionSlab extends AbstractPersistableCustom<Long> {
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
private BigDecimal slabFrom;
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
private BigDecimal slabTo;
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
private BigDecimal commissionPercent;
protected CommissionSlab() {
//
}
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
this.slabType = slabType;
this.slabFrom = slabFrom;
this.slabTo = slabTo;
this.commissionPercent = commissionPercent;
}
}
@@ -61,15 +61,4 @@ 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();
}
}
@@ -72,26 +72,11 @@ 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 String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -84,49 +84,6 @@ 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() {}
/**
@@ -162,15 +162,6 @@ 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";
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getBusinessLocalDate();
this.initiatedOn = DateUtils.getLocalDateOfTenant();
}
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -484,14 +484,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
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
@@ -562,21 +554,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
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, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(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);
@@ -631,44 +612,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* 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)
@@ -719,13 +662,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.getBusinessLocalDate();
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
}
/** Called after successful 3rd-party verification of all required documents. */
public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = DateUtils.getBusinessLocalDate();
this.verifiedOn = DateUtils.getLocalDateOfTenant();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -734,45 +677,17 @@ 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.getBusinessLocalDate();
this.rejectedOn = DateUtils.getLocalDateOfTenant();
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.getBusinessLocalDate();
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -798,28 +713,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() {
this.aadhaarVerified = true;
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markGstinVerified() {
this.gstinVerified = true;
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markCinVerified() {
this.cinVerified = true;
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markDinVerified() {
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber;
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.isDirector = true;
this.dinMcaResponse = mcaResponseJson;
}
@@ -840,17 +755,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else if (type != null && type.isNonIndividual()) {
allDone = this.panVerified && this.gstinVerified;
}
// 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) {
if (allDone && !getKycStatus().isVerified()) {
markVerified(this.ckycNumber);
}
return allDone;
@@ -893,9 +798,6 @@ 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() {
@@ -18,7 +18,6 @@
*/
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;
@@ -49,18 +48,6 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
@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
@@ -93,26 +93,6 @@ 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() {}
/**
@@ -124,9 +104,8 @@ public class ClientRiskRule {
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 String dormantFlag) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleKind = ruleKind;
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
@@ -158,19 +137,6 @@ public class ClientRiskRule {
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;
}
@@ -1,106 +0,0 @@
/**
* 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;
}
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder(toBuilder = true)
public final class BatchRunData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String jobType;
private final String branchScope;
private final LocalDate jobDate;
private final Long agentFrom;
private final Long agentTo;
private final Long partyFrom;
private final Long partyTo;
private final String status;
private final Integer totalRows;
private final BigDecimal totalAmount;
private final LocalDate generatedOnDate;
private final LocalDate postedOnDate;
private final Collection<BatchRunRowData> rows;
}
@@ -0,0 +1,41 @@
/**
* 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.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class BatchRunRowData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final BigDecimal amount;
}
@@ -0,0 +1,122 @@
/**
* 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.dds.batch.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
* seen).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run")
public class BatchRun extends AbstractPersistableCustom<Long> {
@Column(name = "job_type", length = 40, nullable = false)
private String jobType;
@Column(name = "branch_scope", length = 20, nullable = false)
private String branchScope;
@Column(name = "job_date", nullable = false)
private LocalDate jobDate;
@Column(name = "agent_from", nullable = false)
private Long agentFrom;
@Column(name = "agent_to", nullable = false)
private Long agentTo;
@Column(name = "party_from")
private Long partyFrom;
@Column(name = "party_to")
private Long partyTo;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "total_rows", nullable = false)
private Integer totalRows;
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal totalAmount;
@Column(name = "generated_on_date", nullable = false)
private LocalDate generatedOnDate;
@Column(name = "posted_on_date")
private LocalDate postedOnDate;
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<BatchRunRow> rows = new ArrayList<>();
protected BatchRun() {
//
}
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
this.jobType = jobType;
this.branchScope = branchScope;
this.jobDate = jobDate;
this.agentFrom = agentFrom;
this.agentTo = agentTo;
this.partyFrom = partyFrom;
this.partyTo = partyTo;
this.status = "CALCULATED";
this.totalRows = 0;
this.totalAmount = BigDecimal.ZERO;
this.generatedOnDate = generatedOnDate;
}
public void addRow(final BatchRunRow row) {
row.setBatchRun(this);
this.rows.add(row);
this.totalRows = this.rows.size();
this.totalAmount = this.totalAmount.add(row.getAmount());
}
public boolean isPosted() {
return "POSTED".equals(this.status);
}
public void markPosted(final LocalDate postedOnDate) {
this.status = "POSTED";
this.postedOnDate = postedOnDate;
}
}
@@ -0,0 +1,65 @@
/**
* 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.dds.batch.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's accrued interest;
* for a commission job, one Agent's commission on their collected volume (partyId is null there).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run_row")
public class BatchRunRow extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "batch_run_id", nullable = false)
private BatchRun batchRun;
@Column(name = "party_id")
private Long partyId;
@Column(name = "agent_id", nullable = false)
private Long agentId;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
protected BatchRunRow() {
//
}
public BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount) {
this.partyId = partyId;
this.agentId = agentId;
this.amount = amount;
}
void setBatchRun(final BatchRun batchRun) {
this.batchRun = batchRun;
}
}
@@ -0,0 +1,32 @@
/**
* 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.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
* matching the calculate-then-post safety control (never re-post the same preview).
*/
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
public BatchRunAlreadyPostedException(final Long batchRunId) {
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
}
}
@@ -16,13 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.exception;
package org.apache.fineract.portfolio.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserPendingRequestNotFoundException(final Long id) {
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
public BatchRunNotFoundException(final Long id) {
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,107 @@
/**
* 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.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
/**
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
* positional args unreadable and error-prone to keep in sync.
*/
@Getter
@Builder(toBuilder = true)
public final class PartyData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final Long agentId;
private final String agentCode;
private final String agentName;
private final Long savingsAccountId;
private final String subType;
private final String partyCode;
private final String lastAccount;
private final Boolean nominal;
private final String aadharNo;
private final String partyName;
private final String partyName1;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String gender;
private final String panCardNo;
private final String telephone;
private final String mobileNoSms;
private final String operation;
private final LocalDate birthDate;
private final String occupation;
private final String risk;
private final String ckycId;
private final String adultMinor;
private final LocalDate minorDate;
private final Long guardianClientId;
private final String minAccountOpt;
private final String relation;
private final LocalDate openingDate;
private final Integer month;
private final LocalDate maturityDate;
private final LocalDate reopenDate;
private final BigDecimal depositAmount;
private final BigDecimal dailyDepositAmount;
private final Integer days;
private final String ledgerNo;
private final BigDecimal maturityAmount;
private final BigDecimal interestRate;
private final String withdrawType;
private final Boolean od1Flag;
private final Boolean od2Flag;
private final Boolean lien;
private final LocalDate payDate;
private final BigDecimal paymentAmount;
private final BigDecimal currentBalance;
private final BigDecimal openingBalance;
private final Boolean isSmsActive;
private final Boolean isEmailActive;
private final BigDecimal interestCalc;
private final LocalDate interestDate;
private final String spDescription;
private final String freezeCode;
private final String advCode;
private final String creditToBranch;
private final String creditToAccountType;
private final String creditToAccount;
private final Boolean nomineeApplicable;
private final String nomineeSplitMode;
private final String status;
private final LocalDate closeDate;
private final Collection<PartyNomineeData> nominees;
}
@@ -0,0 +1,59 @@
/**
* 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.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class PartyNomineeData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate nominationDate;
private final String name;
private final String mobileNo;
private final LocalDate birthDate;
private final Boolean minor;
private final String relation;
private final String gender;
private final String guardianName;
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName);
}
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
this.id = id;
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = mobileNo;
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = guardianName;
}
}
@@ -0,0 +1,569 @@
/**
* 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.dds.party.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
*
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
* are this record's own, not mirrored from any m_savings_account row.
*/
@Getter
@Entity
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
public class Party extends AbstractPersistableCustom<Long> {
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_CLOSED = "CLOSED";
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "savings_account_id")
private Long savingsAccountId;
@Column(name = "sub_type", length = 60, nullable = false)
private String subType;
@Column(name = "party_code", length = 20, nullable = false)
private String partyCode;
@Column(name = "last_account", length = 50)
private String lastAccount;
@Column(name = "nominal", nullable = false)
private boolean nominal;
@Column(name = "aadhar_no", length = 20)
private String aadharNo;
@Column(name = "party_name", length = 100, nullable = false)
private String partyName;
@Column(name = "party_name1", length = 100)
private String partyName1;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "address3", length = 200)
private String address3;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pincode", length = 20, nullable = false)
private String pincode;
@Column(name = "gender", length = 20)
private String gender;
@Column(name = "pan_card_no", length = 20)
private String panCardNo;
@Column(name = "telephone", length = 20)
private String telephone;
@Column(name = "mobile_no_sms", length = 20, nullable = false)
private String mobileNoSms;
@Column(name = "operation", length = 20, nullable = false)
private String operation;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "occupation", length = 40)
private String occupation;
@Column(name = "risk", length = 20)
private String risk;
@Column(name = "ckyc_id", length = 40)
private String ckycId;
@Column(name = "adult_minor", length = 20, nullable = false)
private String adultMinor;
@Column(name = "minor_date")
private LocalDate minorDate;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "min_account_opt", length = 40)
private String minAccountOpt;
@Column(name = "relation", length = 40)
private String relation;
@Column(name = "opening_date", nullable = false)
private LocalDate openingDate;
@Column(name = "months")
private Integer month;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "reopen_date")
private LocalDate reopenDate;
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal depositAmount;
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal dailyDepositAmount;
@Column(name = "days", nullable = false)
private Integer days;
@Column(name = "ledger_no", length = 40)
private String ledgerNo;
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal maturityAmount;
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal interestRate;
@Column(name = "withdraw_type", length = 20)
private String withdrawType;
@Column(name = "od1_flag", nullable = false)
private boolean od1Flag;
@Column(name = "od2_flag", nullable = false)
private boolean od2Flag;
@Column(name = "lien", nullable = false)
private boolean lien;
@Column(name = "pay_date")
private LocalDate payDate;
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal paymentAmount;
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal currentBalance;
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal openingBalance;
@Column(name = "is_sms_active", nullable = false)
private boolean isSmsActive;
@Column(name = "is_email_active", nullable = false)
private boolean isEmailActive;
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
private BigDecimal interestCalc;
@Column(name = "interest_date")
private LocalDate interestDate;
@Column(name = "sp_description", length = 500)
private String spDescription;
@Column(name = "freeze_code", length = 20, nullable = false)
private String freezeCode;
@Column(name = "adv_code", length = 40, nullable = false)
private String advCode;
@Column(name = "credit_to_branch", length = 100)
private String creditToBranch;
@Column(name = "credit_to_account_type", length = 100)
private String creditToAccountType;
@Column(name = "credit_to_account", length = 50)
private String creditToAccount;
@Column(name = "nominee_applicable", nullable = false)
private boolean nomineeApplicable;
@Column(name = "nominee_split_mode", length = 20)
private String nomineeSplitMode;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "close_date")
private LocalDate closeDate;
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<PartyNominee> nominees = new ArrayList<>();
protected Party() {
//
}
private Party(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
this.client = client;
this.agent = agent;
this.partyCode = partyCode;
this.subType = command.stringValueOfParameterNamed("subType");
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
this.partyName = command.stringValueOfParameterNamed("partyName");
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
this.address1 = command.stringValueOfParameterNamed("address1");
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
this.city = command.stringValueOfParameterNamed("city");
this.pincode = command.stringValueOfParameterNamed("pincode");
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
this.operation = command.stringValueOfParameterNamed("operation");
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
this.days = command.integerValueOfParameterNamed("days");
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
this.maturityAmount = BigDecimal.ZERO;
this.interestRate = command.parameterExists("interestRate")
? command.bigDecimalValueOfParameterNamed("interestRate")
: DEFAULT_INTEREST_RATE;
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
this.od1Flag = false;
this.od2Flag = false;
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
this.payDate = command.localDateValueOfParameterNamed("payDate");
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
this.currentBalance = this.openingBalance;
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
this.interestCalc = BigDecimal.ZERO;
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
"Percentage");
this.status = STATUS_ACTIVE;
}
public static Party fromJson(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
return new Party(client, agent, partyCode, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addNominee(final PartyNominee nominee) {
nominee.setParty(this);
this.nominees.add(nominee);
}
public void resetNominees() {
this.nominees.clear();
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public void changeAgent(final Agent newAgent) {
this.agent = newAgent;
}
/**
* Links this Party to an existing real Savings Account, resolved by account number in the write
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
* tied.
*/
public void linkSavingsAccount(final Long savingsAccountId) {
this.savingsAccountId = savingsAccountId;
}
public BigDecimal credit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.add(amount);
return this.currentBalance;
}
public BigDecimal debit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.subtract(amount);
return this.currentBalance;
}
public boolean isClosed() {
return STATUS_CLOSED.equals(this.status);
}
/**
* Closes this Party's account as of the given date. The balance must already be zero - a Party
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
* still owed to it.
*/
public void close(final LocalDate closeDate) {
this.status = STATUS_CLOSED;
this.closeDate = closeDate;
}
/**
* Stamps the date interest (regular or OD) was last calculated up to, so the next batch run
* accrues only the days since. Set on POST, not on calculate/preview.
*/
public void markInterestCalculatedThrough(final LocalDate date) {
this.interestDate = date;
}
public Long clientId() {
return this.client.getId();
}
public Long agentId() {
return this.agent.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
}
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
actualChanges.put("nominal", newValue);
this.nominal = newValue;
}
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
}
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
}
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
actualChanges.put("guardianClientId", newValue);
this.guardianClientId = newValue;
}
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
}
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
final Integer newValue = command.integerValueOfParameterNamed("month");
actualChanges.put("month", newValue);
this.month = newValue;
}
if (command.isChangeInDateParameterNamed("maturityDate", this.maturityDate)) {
actualChanges.put("maturityDate", command.stringValueOfParameterNamed("maturityDate"));
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
}
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
}
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
actualChanges.put("depositAmount", newValue);
this.depositAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
actualChanges.put("dailyDepositAmount", newValue);
this.dailyDepositAmount = newValue;
}
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
final Integer newValue = command.integerValueOfParameterNamed("days");
actualChanges.put("days", newValue);
this.days = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
actualChanges.put("interestRate", newValue);
this.interestRate = newValue;
}
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
actualChanges.put("lien", newValue);
this.lien = newValue;
}
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
this.payDate = command.localDateValueOfParameterNamed("payDate");
}
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
actualChanges.put("paymentAmount", newValue);
this.paymentAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
actualChanges.put("openingBalance", newValue);
this.openingBalance = newValue;
}
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
actualChanges.put("isSmsActive", newValue);
this.isSmsActive = newValue;
}
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
actualChanges.put("isEmailActive", newValue);
this.isEmailActive = newValue;
}
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
}
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
actualChanges.put("nomineeApplicable", newValue);
this.nomineeApplicable = newValue;
}
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -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.portfolio.dds.party.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
* the command layer (the whole Party update goes through the standard maker-checker command flow),
* not on this child row individually.
*/
@Getter
@Entity
@Table(name = "m_dds_party_nominee")
public class PartyNominee extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private Party party;
@Column(name = "nomination_date", nullable = false)
private LocalDate nominationDate;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "mobile_no", length = 20)
private String mobileNo;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "minor", nullable = false)
private boolean minor;
@Column(name = "relation", length = 40, nullable = false)
private String relation;
@Column(name = "gender", length = 20, nullable = false)
private String gender;
@Column(name = "guardian_name", length = 100)
private String guardianName;
protected PartyNominee() {
//
}
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
final boolean minor, final String relation, final String gender, final String guardianName) {
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = StringUtils.defaultIfEmpty(mobileNo, null);
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = StringUtils.defaultIfEmpty(guardianName, null);
}
void setParty(final Party party) {
this.party = party;
}
}
@@ -16,13 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.exception;
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class FacilityChargeMappingNotFoundException extends AbstractPlatformResourceNotFoundException {
/**
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
* closed.
*/
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
public FacilityChargeMappingNotFoundException(final Long id) {
super("error.msg.facilitychargemapping.id.invalid", "Facility charge mapping with identifier " + id + " does not exist", id);
public PartyAlreadyClosedException(final String partyCode) {
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
}
}
@@ -0,0 +1,32 @@
/**
* 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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
*/
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
public PartyHasBalanceException(final String partyCode) {
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
}
}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS party resource is not found.
*/
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartyNotFoundException(final Long id) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
}
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
}
public PartyNotFoundException(final String partyCode) {
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
}
}
@@ -0,0 +1,33 @@
/**
* 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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
* own clientId - a Party can only be linked to its own client's savings account.
*/
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
super("error.msg.party.savings.account.client.mismatch",
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
}
}
@@ -16,21 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.domain;
package org.apache.fineract.portfolio.dds.party.exception;
public enum UserPendingRequestType {
CREATE_USER,
UPDATE_USER;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public static UserPendingRequestType fromString(final String type) {
if (type == null) {
return null;
}
for (final UserPendingRequestType requestType : values()) {
if (requestType.name().equalsIgnoreCase(type)) {
return requestType;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
/**
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
*/
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
savingsAccountNo);
}
}
@@ -16,19 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.tds.service;
package org.apache.fineract.portfolio.dds.report.data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
public interface TdsInterestReadPlatformService {
/**
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
* result type per report.
*/
@Getter
@Builder
public final class DdsReportData implements Serializable {
/**
* Deposit interest credited in a financial year, per customer.
*
* @param financialYear
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
* 31 Mar 2027
*/
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
@Serial
private static final long serialVersionUID = 1L;
private final String reportKey;
private final boolean available;
private final String message;
private final List<String> columns;
private final List<Map<String, Object>> rows;
}
@@ -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.portfolio.dds.transaction.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class PartyTransactionData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final LocalDate transactionDate;
private final String transactionType;
private final String direction;
private final String transactionMode;
private final String accountType;
private final String accountNo;
private final BigDecimal amount;
private final String chequeNo;
private final String tokenNo;
private final String description;
private final BigDecimal balanceAfterTransaction;
}
@@ -0,0 +1,110 @@
/**
* 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.dds.transaction.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
/**
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
* an opposing entry, not edited, so there is deliberately no update API for this entity.
*/
@Getter
@Entity
@Table(name = "m_dds_party_transaction")
public class PartyTransaction extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id")
private Party party;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
@Column(name = "transaction_type", length = 30, nullable = false)
private String transactionType;
@Column(name = "direction", length = 10, nullable = false)
private String direction;
@Column(name = "transaction_mode", length = 20)
private String transactionMode;
@Column(name = "account_type", length = 100)
private String accountType;
@Column(name = "account_no", length = 50)
private String accountNo;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
@Column(name = "cheque_no", length = 40)
private String chequeNo;
@Column(name = "token_no", length = 40)
private String tokenNo;
@Column(name = "description", length = 500)
private String description;
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceAfterTransaction;
protected PartyTransaction() {
//
}
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
final String direction, final String transactionMode, final String accountType, final String accountNo,
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
final BigDecimal balanceAfterTransaction) {
this.party = party;
this.agent = agent;
this.transactionDate = transactionDate;
this.transactionType = transactionType;
this.direction = direction;
this.transactionMode = transactionMode;
this.accountType = accountType;
this.accountNo = accountNo;
this.amount = amount;
this.chequeNo = chequeNo;
this.tokenNo = tokenNo;
this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction;
}
}
@@ -0,0 +1,33 @@
/**
* 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.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
* settled) balance.
*/
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientAgentBalanceException(final String agentCode) {
super("error.msg.agent.insufficient.balance",
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
}
}
@@ -16,22 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.domain;
package org.apache.fineract.portfolio.dds.transaction.exception;
public enum UserPendingRequestStatus {
PENDING,
APPROVED,
REJECTED;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public static UserPendingRequestStatus fromString(final String status) {
if (status == null) {
return null;
}
for (final UserPendingRequestStatus requestStatus : values()) {
if (requestStatus.name().equalsIgnoreCase(status)) {
return requestStatus;
}
}
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
/**
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
*/
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientPartyBalanceException(final String partyCode) {
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
partyCode);
}
}
@@ -16,22 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
package org.apache.fineract.portfolio.dds.transaction.exception;
import java.util.List;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* Reads the accounts the dormancy job has marked inactive, dormant or escheated.
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
*/
public interface DormantAccountReadPlatformService {
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
/**
* @param subStatus
* 100 inactive, 200 dormant, 300 escheat; null for all three
* @param officeName
* restricts to one branch; null or blank for every branch
* @return the matching accounts, longest idle first
*/
List<DormantAccountData> retrieveDormantAccounts(Integer subStatus, String officeName);
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
}
}
@@ -31,8 +31,7 @@ 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"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
private final Integer value;
private final String code;
@@ -52,7 +51,6 @@ 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;
};
}
@@ -87,10 +85,6 @@ 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();
@@ -208,7 +208,6 @@ 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";
@@ -589,10 +588,6 @@ 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;
}
@@ -92,9 +92,6 @@ 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";
@@ -229,7 +226,6 @@ 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";
@@ -258,6 +254,7 @@ 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";
@@ -278,6 +275,7 @@ public class SavingsApiConstants {
// Additional custom fields
public static final String balanceFrequencyParamName = "balanceFrequency";
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
public static final String productCategoryParamName = "productCategory";
// Current Product — new UI fields
public static final String balanceSwapOptionParamName = "balanceSwapOption";
@@ -157,8 +157,6 @@ 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;
@@ -219,21 +217,6 @@ 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,
@@ -144,6 +144,7 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private Boolean enableLockinPeriod;
@Getter @Setter private String balanceFrequency;
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
@Getter @Setter private String productCategory;
// New current product fields
@Getter @Setter private Boolean balanceSwapOption;
@@ -256,16 +256,7 @@ public final class PostingPeriod {
this.daysInYear = daysInYear;
this.compoundingPeriods = compoundingPeriods;
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) {
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
this.dateOfPostingTransaction = periodInterval.endDate();
} else {
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
@@ -797,10 +797,6 @@ 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;
}
@@ -77,6 +77,9 @@ 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;
@@ -117,6 +120,7 @@ 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());
@@ -136,6 +136,9 @@ 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;
@@ -378,6 +378,7 @@ 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;
@@ -75,6 +75,9 @@ 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;
@@ -122,6 +125,8 @@ 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; }
@@ -142,6 +147,7 @@ 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();
}
@@ -117,6 +117,7 @@ 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);
@@ -1,243 +0,0 @@
/**
* 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";
};
}
}
@@ -1,112 +0,0 @@
/**
* 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;
}
}
@@ -1,185 +0,0 @@
/**
* 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);
}
}
@@ -1,58 +0,0 @@
/**
* 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);
}
}
}
@@ -25,14 +25,12 @@ 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;
@@ -79,7 +77,6 @@ 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 {
@@ -92,14 +89,6 @@ 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) {
@@ -127,10 +116,6 @@ 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) {
@@ -615,144 +600,6 @@ 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.
@@ -1394,29 +1241,6 @@ 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;
@@ -1439,11 +1263,6 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -170,12 +170,9 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// Post journal entry if earned interest amount is greater than
// zero
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
// 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);
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
}
}
@@ -21,11 +21,9 @@ 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;
@@ -106,8 +104,6 @@ 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;
@@ -140,7 +136,6 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
private final LoanTransactionRepository loanTransactionRepository;
private final VoucherService voucherService;
@Transactional
@Override
@@ -237,73 +232,14 @@ 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).with(changes).build();
.withTransactionId(transactionId).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) {
/**
@@ -392,9 +328,6 @@ 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());
}
}
@@ -779,30 +712,15 @@ 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");
}
@@ -884,55 +802,6 @@ 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);
@@ -52,7 +52,6 @@ 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;
@@ -68,10 +67,10 @@ public class AccountingJournalEntryConfiguration {
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
BusinessEventNotifierService businessEventNotifierService) {
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
businessEventNotifierService, jdbcTemplate);
businessEventNotifierService);
}
@Bean
@@ -99,13 +98,12 @@ public class AccountingJournalEntryConfiguration {
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
ExternalAssetOwnerRepository externalAssetOwnerRepository,
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
LoanTransactionRepository loanTransactionRepository) {
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
voucherService);
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
}
}
@@ -301,15 +301,6 @@ 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);
@@ -1,264 +0,0 @@
/**
* 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());
}
}
@@ -1,180 +0,0 @@
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;
}
}
@@ -1,60 +0,0 @@
/**
* 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);
}
}
@@ -1,61 +0,0 @@
/**
* 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;
}
}
@@ -1,396 +0,0 @@
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;
}
}
@@ -1,52 +0,0 @@
/**
* 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();
}
}
@@ -1,88 +0,0 @@
/**
* 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;
}
}
@@ -61,11 +61,6 @@ 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(
@@ -33,7 +33,6 @@ 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;
@@ -164,24 +163,6 @@ 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()
@@ -427,10 +408,7 @@ public class SecurityConfig {
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.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);
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
if (loanCOBFilterHelper != null) {
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
@@ -467,10 +445,6 @@ public class SecurityConfig {
return new RequestResponseFilter();
}
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
return new DisabledFeatureApiFilter();
}
public LoanCOBApiFilter loanCOBApiFilter() {
return new LoanCOBApiFilter(loanCOBFilterHelper);
}
@@ -1,128 +0,0 @@
/**
* 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\"}");
}
}
@@ -66,23 +66,6 @@ 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) {
@@ -1,109 +0,0 @@
/**
* 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;
}
}
@@ -1,243 +0,0 @@
/**
* 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());
}
}
@@ -1,214 +0,0 @@
/**
* 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());
}
}
@@ -0,0 +1,104 @@
/**
* 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.agent.api;
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.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/agents")
@Component
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
@RequiredArgsConstructor
public class AgentsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
private final PlatformSecurityContext context;
private final AgentReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
public Collection<AgentData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(agentId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createAgent() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateAgent(agentId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -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.organisation.agent.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AgentRepository extends JpaRepository<Agent, Long> {
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
Long retrieveMaxAgentCodeSuffix();
Optional<Agent> findByAgentCode(String agentCode);
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
}
@@ -16,27 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
package org.apache.fineract.organisation.agent.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
@CommandType(entity = "AGENT", action = "CREATE")
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
private final FdBaseInterestRateWritePlatformService writePlatformService;
private final AgentWritePlatformService writePlatformService;
@Autowired
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveInterestRates(command);
return this.writePlatformService.createAgent(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.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.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "UPDATE")
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateAgent(command.entityId(), command);
}
}
@@ -0,0 +1,409 @@
/**
* 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.agent.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
* original identity/settlement-account fields.
*/
@Component
public final class AgentCommandFromApiJsonDeserializer {
public static final String CLIENT_ID = "clientId";
public static final String AGENT_NAME_ID = "agentNameId";
public static final String AGENT_ADDRESS1 = "agentAddress1";
public static final String AGENT_ADDRESS2 = "agentAddress2";
public static final String AGENT_CITY = "agentCity";
public static final String AGENT_PINCODE = "agentPinCode";
public static final String AGENT_TELEPHONE = "agentTelephone";
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
public static final String HOME_BRANCH = "homeBranch";
public static final String SAVING_BRANCH = "savingBranch";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_PERCENT = "tdsPercent";
public static final String TRANSFER_BRANCH = "transferBranch";
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
public static final String COMMISSION_CALCULATION = "commissionCalculation";
public static final String LIMIT = "limit";
public static final String MACHINE_RENT = "machineRent";
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
public static final String OTHER_COMMISSION = "otherCommission";
public static final String AGENT_DEPOSIT = "agentDeposit";
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
public static final String OTHER_DEDUCTION = "otherDeduction";
public static final String LIMITS = "limits";
public static final String GUARANTORS = "guarantors";
public static final String DATE_FORMAT = "dateFormat";
public static final String LOCALE = "locale";
private static final String GUARANTOR_CLIENT_ID = "clientId";
private static final String GUARANTOR_NAME = "guarantorName";
private static final String GUARANTOR_ADDRESS1 = "address1";
private static final String GUARANTOR_ADDRESS2 = "address2";
private static final String GUARANTOR_CITY = "city";
private static final String GUARANTOR_PINCODE = "pinCode";
private static final String GUARANTOR_TELEPHONE = "telephone";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
element));
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
baseDataValidator.reset().parameter(TDS_APPLICABLE)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
}
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
}
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
.ignoreIfNull().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
.notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
}
}
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
.notBlank().notExceedingLengthOf(200);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
.notBlank().notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private Long extractLong(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
}
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,29 @@
/**
* 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.agent.service;
import java.util.Collection;
import org.apache.fineract.organisation.agent.data.AgentData;
public interface AgentReadPlatformService {
Collection<AgentData> retrieveAll();
AgentData retrieveOne(Long agentId);
}
@@ -0,0 +1,176 @@
/**
* 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.agent.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
import org.apache.fineract.organisation.agent.data.AgentLimitData;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
private static final class AgentMapper implements RowMapper<AgentData> {
public String schema() {
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
}
@Override
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
new ArrayList<>(), new ArrayList<>());
}
}
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
public String schema() {
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
}
@Override
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
return Map.entry(agentId, data);
}
}
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
public String schema() {
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
+ " g.telephone as telephone from m_dds_agent_guarantor g";
}
@Override
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
rs.getString("pinCode"), rs.getString("telephone"));
return Map.entry(agentId, data);
}
}
@Override
public Collection<AgentData> retrieveAll() {
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
return attachChildren(agents);
}
@Override
public AgentData retrieveOne(final Long agentId) {
try {
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
return attachChildren(List.of(agent)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new AgentNotFoundException(agentId, e);
}
}
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
if (agents.isEmpty()) {
return agents;
}
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<AgentData> result = new ArrayList<>(agents.size());
for (final AgentData agent : agents) {
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
limits, guarantors));
}
return result;
}
}
@@ -0,0 +1,29 @@
/**
* 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.agent.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface AgentWritePlatformService {
CommandProcessingResult createAgent(JsonCommand command);
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
}
@@ -0,0 +1,187 @@
/**
* 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.agent.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
import org.apache.fineract.organisation.agent.domain.AgentLimit;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final AgentRepository agentRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
@Transactional
@Override
public CommandProcessingResult createAgent(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final String agentCode = generateNextAgentCode();
final Agent agent = Agent.fromJson(client, agentCode, command);
addLimitsFromCommand(agent, command);
addGuarantorsFromCommand(agent, command);
this.agentRepository.saveAndFlush(agent);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agent.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final Map<String, Object> changesOnly = agentForUpdate.update(command);
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
agentForUpdate.changeClient(newClient);
}
if (command.parameterExists("limits")) {
agentForUpdate.resetLimits();
addLimitsFromCommand(agentForUpdate, command);
changesOnly.put("limits", command.jsonFragment("limits"));
}
if (command.parameterExists("guarantors")) {
agentForUpdate.resetGuarantors();
addGuarantorsFromCommand(agentForUpdate, command);
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
}
this.agentRepository.saveAndFlush(agentForUpdate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("limits")) {
return;
}
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
if (limitsArray == null) {
return;
}
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
}
}
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("guarantors")) {
return;
}
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
if (guarantorsArray == null) {
return;
}
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
final String address1 = guarantorJson.get("address1").getAsString();
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
? guarantorJson.get("address2").getAsString()
: null;
final String city = guarantorJson.get("city").getAsString();
final String pinCode = guarantorJson.get("pinCode").getAsString();
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
? guarantorJson.get("telephone").getAsString()
: null;
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
}
}
private String generateNextAgentCode() {
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
return String.format("AG%04d", nextSuffix);
}
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -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.organisation.agent.starter;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationAgentConfiguration {
@Bean
@ConditionalOnMissingBean(AgentReadPlatformService.class)
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AgentReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AgentWritePlatformService.class)
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
}
}
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.api;
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.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/commissionslabs")
@Component
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
@RequiredArgsConstructor
public class CommissionSlabsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
private final PlatformSecurityContext context;
private final CommissionSlabReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
public Collection<CommissionSlabData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createCommissionSlabs() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -16,15 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.domain;
package org.apache.fineract.organisation.commissionslab.domain;
import java.util.Optional;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
public interface CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
boolean existsByPendingLockKey(String pendingLockKey);
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
}
@@ -16,31 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.handler;
package org.apache.fineract.organisation.commissionslab.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.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
@CommandType(entity = "COMMISSIONSLAB", action = "CREATE")
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final CommissionSlabWritePlatformService writePlatformService;
@Autowired
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
return this.writePlatformService.createCommissionSlabs(command);
}
}

Some files were not shown because too many files have changed in this diff Show More