Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1de1aab93 |
+10
-1
@@ -37,4 +37,13 @@ gradleExp/
|
|||||||
.windsurf/
|
.windsurf/
|
||||||
|
|
||||||
# Application logs
|
# Application logs
|
||||||
fineract-provider/logs/
|
fineract-provider/logs/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Output of a local `gradlew devRun`
|
||||||
|
devrun.log
|
||||||
|
|
||||||
|
# JVM crash dumps. The dev setup runs Fineract, the Angular dev server and Gradle on one
|
||||||
|
# machine, so an out-of-memory kill drops these in the repo root.
|
||||||
|
hs_err_pid*.log
|
||||||
|
replay_pid*.log
|
||||||
@@ -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)
|
(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);
|
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
|
INSERT IGNORE INTO m_charge
|
||||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
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)),
|
('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)),
|
('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)),
|
('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)),
|
('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));
|
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
||||||
|
|||||||
@@ -17,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 neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_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 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 deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_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_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_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_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_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);
|
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 neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_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 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 deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_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_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_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_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_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);
|
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||||
|
|||||||
+9
-20
@@ -45,22 +45,6 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
*/
|
*/
|
||||||
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
||||||
|
|
||||||
/**
|
|
||||||
* Activities an administrator may map from the Financial Activity Mappings screen.
|
|
||||||
*
|
|
||||||
* Held in one place because create and update both validate against it, and the two lists had already drifted -
|
|
||||||
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
|
|
||||||
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
|
|
||||||
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
|
|
||||||
*/
|
|
||||||
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
|
|
||||||
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
|
|
||||||
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
|
|
||||||
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
|
|
||||||
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
|
|
||||||
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
|
|
||||||
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
|
|
||||||
|
|
||||||
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
||||||
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||||
|
|
||||||
@@ -75,8 +59,11 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||||
|
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
||||||
.isOneOfTheseValues(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());
|
||||||
|
|
||||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||||
@@ -99,8 +86,10 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
||||||
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
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)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||||
|
|||||||
-5
@@ -37,7 +37,6 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
||||||
GL_ACCOUNT_DISABLED, //
|
GL_ACCOUNT_DISABLED, //
|
||||||
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
||||||
GL_ACCOUNT_IS_HEADER, //
|
|
||||||
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
||||||
|
|
||||||
public String errorMessage() {
|
public String errorMessage() {
|
||||||
@@ -59,8 +58,6 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "Invalid debit or credit accounts are passed";
|
return "Invalid debit or credit accounts are passed";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "Target account does not allow manual adjustments";
|
return "Target account does not allow manual adjustments";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
|
||||||
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
|
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
@@ -82,8 +79,6 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
|
||||||
return "error.msg.glJournalEntry.invalid.account.is.header";
|
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-29
@@ -181,14 +181,10 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
||||||
.notNull().integerGreaterThanZero();
|
.notNull().integerGreaterThanZero();
|
||||||
|
|
||||||
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
.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)) {
|
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||||
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
|
final Long overdraftAccount = this.fromApiJsonHelper
|
||||||
// only, so they are validated when supplied rather than demanded from every savings product.
|
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||||
element)) {
|
.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
|
||||||
final Long incomeFromInterest = this.fromApiJsonHelper
|
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
.notNull().integerGreaterThanZero();
|
||||||
.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(),
|
||||||
final Long writtenOff = this.fromApiJsonHelper
|
element);
|
||||||
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
.integerGreaterThanZero();
|
||||||
.notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -426,8 +426,6 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
@@ -472,8 +470,6 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
|
|||||||
-18
@@ -106,20 +106,6 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
PortfolioProductType.SAVING);
|
PortfolioProductType.SAVING);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create-or-merge, for an expense row a product may not have yet.
|
|
||||||
*
|
|
||||||
* The plain merge variant assumes the mapping already exists, which is true of every expense
|
|
||||||
* row that shipped with the product screen. A row added later — the rounding adjustment — is
|
|
||||||
* absent on every product created before it, so editing one of those has to create the
|
|
||||||
* mapping rather than fail looking for it.
|
|
||||||
*/
|
|
||||||
public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName,
|
|
||||||
final Long productId, final int accountTypeId, final Map<String, Object> changes) {
|
|
||||||
createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE,
|
|
||||||
PortfolioProductType.SAVING);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*** Abstractions for payments channel related to savings products ***/
|
/*** Abstractions for payments channel related to savings products ***/
|
||||||
|
|
||||||
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
||||||
@@ -262,8 +248,6 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
|
||||||
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
// asset
|
// asset
|
||||||
@@ -324,8 +308,6 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
|
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
|
||||||
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_UPFRONT:
|
case ACCRUAL_UPFRONT:
|
||||||
|
|||||||
+5
-43
@@ -269,15 +269,7 @@ public final class AccountingConstants {
|
|||||||
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
||||||
INCOME_FROM_INTEREST(12), //
|
INCOME_FROM_INTEREST(12), //
|
||||||
LOSSES_WRITTEN_OFF(13), //
|
LOSSES_WRITTEN_OFF(13), //
|
||||||
ESCHEAT_LIABILITY(14), //
|
ESCHEAT_LIABILITY(14); //
|
||||||
/**
|
|
||||||
* Where the paise dropped from a cash closure are absorbed.
|
|
||||||
*
|
|
||||||
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
|
|
||||||
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
|
|
||||||
* well clear of the accrual slots so the two sets never collide as either grows.
|
|
||||||
*/
|
|
||||||
ROUNDING_ADJUSTMENT(26); //
|
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -325,9 +317,7 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE(15), //
|
FEES_RECEIVABLE(15), //
|
||||||
PENALTIES_RECEIVABLE(16), //
|
PENALTIES_RECEIVABLE(16), //
|
||||||
INTEREST_PAYABLE(17), //
|
INTEREST_PAYABLE(17), //
|
||||||
INTEREST_RECEIVABLE(18), //
|
INTEREST_RECEIVABLE(18);
|
||||||
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
|
|
||||||
ROUNDING_ADJUSTMENT(26);
|
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -382,8 +372,7 @@ public final class AccountingConstants {
|
|||||||
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
||||||
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccountId"), //
|
INTEREST_PAYABLE("interestPayableAccountId");
|
||||||
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
|
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -421,8 +410,7 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE("feeReceivableAccount"), //
|
FEES_RECEIVABLE("feeReceivableAccount"), //
|
||||||
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccount"), //
|
INTEREST_PAYABLE("interestPayableAccount"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccount"), //
|
INTEREST_RECEIVABLE("interestReceivableAccount"); //
|
||||||
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
|
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -449,34 +437,8 @@ public final class AccountingConstants {
|
|||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
|
||||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||||
/**
|
|
||||||
* The year's closed-out profit a dividend is declared against.
|
|
||||||
*
|
|
||||||
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
|
||||||
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
|
||||||
* distribution a second time.
|
|
||||||
*/
|
|
||||||
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
|
||||||
/**
|
|
||||||
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
|
||||||
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
|
||||||
* calculation.
|
|
||||||
*/
|
|
||||||
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
|
||||||
/**
|
|
||||||
* Where the paise a dividend cannot divide evenly are collected.
|
|
||||||
*
|
|
||||||
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
|
||||||
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
|
||||||
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
|
||||||
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
|
||||||
*
|
|
||||||
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
|
||||||
*/
|
|
||||||
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+1
-20
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,25 +151,6 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
|
||||||
if (!user.isCheckerSuperUser()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
-33
@@ -664,14 +664,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
|
||||||
this.actionName = "DELETE";
|
|
||||||
this.entityName = "OFFICE";
|
|
||||||
this.entityId = officeId;
|
|
||||||
this.href = "/offices/" + officeId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder createOfficeTransaction() {
|
public CommandWrapperBuilder createOfficeTransaction() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICETRANSACTION";
|
this.entityName = "OFFICETRANSACTION";
|
||||||
@@ -2219,14 +2211,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
@@ -2913,15 +2897,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||||
this.actionName = "CALCULATEINTEREST";
|
this.actionName = "CALCULATEINTEREST";
|
||||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||||
@@ -3128,14 +3103,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||||
this.actionName = "DELETE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||||
|
|||||||
-2
@@ -26,8 +26,6 @@ public interface ConfigurationDomainService {
|
|||||||
|
|
||||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||||
|
|
||||||
boolean isMakerCheckerEnabled();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
* 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
|
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||||
|
|||||||
-4
@@ -39,10 +39,7 @@ public enum JobName {
|
|||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
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"), //
|
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_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions 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"), //
|
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||||
@@ -56,7 +53,6 @@ public enum JobName {
|
|||||||
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
||||||
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
||||||
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
||||||
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
|
|
||||||
LOAN_COB("Loan COB"), //
|
LOAN_COB("Loan COB"), //
|
||||||
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
||||||
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
||||||
|
|||||||
-11
@@ -61,15 +61,4 @@ public class OfficeRepositoryWrapper {
|
|||||||
public void delete(final Office entity) {
|
public void delete(final Office entity) {
|
||||||
this.repository.delete(entity);
|
this.repository.delete(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
|
|
||||||
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
|
|
||||||
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
|
|
||||||
* than at transaction-commit time where it can no longer be turned into a clean error message.
|
|
||||||
*/
|
|
||||||
public void deleteAndFlush(final Office entity) {
|
|
||||||
this.repository.delete(entity);
|
|
||||||
this.repository.flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-16
@@ -72,26 +72,11 @@ public class SavingsAccountSummaryData {
|
|||||||
// differentiate deposit accounts Savings, FD and RD accounts
|
// differentiate deposit accounts Savings, FD and RD accounts
|
||||||
private final EnumOptionData depositType;
|
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,
|
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 String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
||||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
|
||||||
this.fdAccountNumber = fdAccountNumber;
|
|
||||||
this.renewalCycleNo = renewalCycleNo;
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
-43
@@ -84,49 +84,6 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
|
||||||
/**
|
|
||||||
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
|
||||||
* A rule named for withdrawals must not fire on a deposit.
|
|
||||||
*/
|
|
||||||
@Column(name = "direction", length = 10)
|
|
||||||
private String direction;
|
|
||||||
|
|
||||||
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
|
||||||
@Column(name = "cash_only", nullable = false)
|
|
||||||
private boolean cashOnly;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
|
||||||
* many small transactions; without this floor a single large one satisfies the window total and
|
|
||||||
* reads as a pattern it is not.
|
|
||||||
*/
|
|
||||||
@Column(name = "min_transaction_count")
|
|
||||||
private Integer minTransactionCount;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which customers a rule is about, where the rule is about the customer rather than the
|
|
||||||
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
|
||||||
* about the transaction alone and so applies to everyone.
|
|
||||||
*/
|
|
||||||
@Column(name = "subject_scope", length = 30)
|
|
||||||
private String subjectScope;
|
|
||||||
|
|
||||||
public String getSubjectScope() {
|
|
||||||
return subjectScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDirection() {
|
|
||||||
return direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isCashOnly() {
|
|
||||||
return cashOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getMinTransactionCount() {
|
|
||||||
return minTransactionCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected AmlTransactionRule() {}
|
protected AmlTransactionRule() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
-9
@@ -162,15 +162,6 @@ public class ClientApiConstants {
|
|||||||
public static final String ageDaysParamName = "ageDays";
|
public static final String ageDaysParamName = "ageDays";
|
||||||
public static final String nationalityIdParamName = "nationalityId";
|
public static final String nationalityIdParamName = "nationalityId";
|
||||||
public static final String isMinorParamName = "isMinor";
|
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 guardianClientIdParamName = "guardianClientId";
|
||||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||||
public static final String guardianRelationParamName = "guardianRelation";
|
public static final String guardianRelationParamName = "guardianRelation";
|
||||||
|
|||||||
+15
-113
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
this.dinVerified = false;
|
this.dinVerified = false;
|
||||||
this.isException = false;
|
this.isException = false;
|
||||||
this.exceptionResolved = false;
|
this.exceptionResolved = false;
|
||||||
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||||
@@ -484,14 +484,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
recordOverwrite(riskParam, this.riskCategory);
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
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
|
// 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);
|
v -> this.form15Applicable = v);
|
||||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||||
// The four exception columns are derived from whether the identity checks have passed, not
|
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||||
// data an officer types, so they are updated without recording an overwrite. A verified
|
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||||
// record demands remarks for any field it already held (see
|
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||||
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
|
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||||
// counting these would make closing a settled exception impossible without a justification
|
|
||||||
// for something nobody chose. That is what left clients verified on the KYC tab while the
|
|
||||||
// exceptions register still listed them as Pending.
|
|
||||||
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
|
|
||||||
v -> this.isException = v != null ? v : false);
|
|
||||||
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
|
|
||||||
v -> this.exceptionReason = v);
|
|
||||||
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
|
|
||||||
v -> this.exceptionResolved = v != null ? v : false);
|
|
||||||
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
|
|
||||||
v -> this.exceptionResolvedOn = v);
|
|
||||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||||
@@ -631,44 +612,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
* populated is the record being completed rather than altered, so it is deliberately not
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
*/
|
*/
|
||||||
/*
|
|
||||||
* Derived-field variants of the update helpers above.
|
|
||||||
*
|
|
||||||
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
|
||||||
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
|
||||||
* kind of change a verified record demands a justification for. They still report into
|
|
||||||
* `changes`, so the update is persisted and audited like any other.
|
|
||||||
*/
|
|
||||||
|
|
||||||
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
|
||||||
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
|
|
||||||
if (command.parameterExists(param)) {
|
|
||||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
|
||||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
|
||||||
changes.put(param, newValue);
|
|
||||||
setter.accept(newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
|
|
||||||
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
|
|
||||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed(param);
|
|
||||||
changes.put(param, newValue);
|
|
||||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
|
|
||||||
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
|
|
||||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
|
||||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
|
||||||
changes.put(param, newValue);
|
|
||||||
setter.accept(newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void recordOverwrite(final String param, final Object currentValue) {
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
final boolean wasPopulated = currentValue instanceof String text
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
? StringUtils.isNotBlank(text)
|
? StringUtils.isNotBlank(text)
|
||||||
@@ -719,13 +662,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
||||||
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
|
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called after successful 3rd-party verification of all required documents. */
|
/** Called after successful 3rd-party verification of all required documents. */
|
||||||
public void markVerified(final String ckycNumber) {
|
public void markVerified(final String ckycNumber) {
|
||||||
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
||||||
this.verifiedOn = DateUtils.getBusinessLocalDate();
|
this.verifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
|
|
||||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||||
@@ -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. */
|
/** Called when verification fails. */
|
||||||
public void markRejected(final String remarks) {
|
public void markRejected(final String remarks) {
|
||||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||||
this.rejectedOn = DateUtils.getBusinessLocalDate();
|
this.rejectedOn = DateUtils.getLocalDateOfTenant();
|
||||||
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
||||||
final String aadhaarSeedingStatus, final String category, final String status) {
|
final String aadhaarSeedingStatus, final String category, final String status) {
|
||||||
this.panVerified = true;
|
this.panVerified = true;
|
||||||
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
||||||
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
||||||
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
||||||
@@ -798,28 +713,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public void markAadhaarVerified() {
|
public void markAadhaarVerified() {
|
||||||
this.aadhaarVerified = true;
|
this.aadhaarVerified = true;
|
||||||
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markGstinVerified() {
|
public void markGstinVerified() {
|
||||||
this.gstinVerified = true;
|
this.gstinVerified = true;
|
||||||
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markCinVerified() {
|
public void markCinVerified() {
|
||||||
this.cinVerified = true;
|
this.cinVerified = true;
|
||||||
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified() {
|
public void markDinVerified() {
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
||||||
this.dinNumber = dinNumber;
|
this.dinNumber = dinNumber;
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||||
this.isDirector = true;
|
this.isDirector = true;
|
||||||
this.dinMcaResponse = mcaResponseJson;
|
this.dinMcaResponse = mcaResponseJson;
|
||||||
}
|
}
|
||||||
@@ -840,17 +755,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
} else if (type != null && type.isNonIndividual()) {
|
} else if (type != null && type.isNonIndividual()) {
|
||||||
allDone = this.panVerified && this.gstinVerified;
|
allDone = this.panVerified && this.gstinVerified;
|
||||||
}
|
}
|
||||||
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
|
if (allDone && !getKycStatus().isVerified()) {
|
||||||
// periodic re-KYC is: an already-verified customer verified afresh.
|
|
||||||
//
|
|
||||||
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
|
|
||||||
// that now measures from it — frozen at the original onboarding. An officer who re-verified
|
|
||||||
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
|
|
||||||
// account still sitting on the inactive report, with nothing to say what was missing.
|
|
||||||
//
|
|
||||||
// Guarded on the verifications rather than the status, so this only fires when the checks
|
|
||||||
// that constitute a verification have actually been done again.
|
|
||||||
if (allDone) {
|
|
||||||
markVerified(this.ckycNumber);
|
markVerified(this.ckycNumber);
|
||||||
}
|
}
|
||||||
return allDone;
|
return allDone;
|
||||||
@@ -893,9 +798,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
*/
|
*/
|
||||||
public void escalateRiskCategory(Integer newCategory) {
|
public void escalateRiskCategory(Integer newCategory) {
|
||||||
this.riskCategory = 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() {
|
public String getPanNumber() {
|
||||||
|
|||||||
-13
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -49,18 +48,6 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
|||||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||||
List<ClientKyc> findAllByStatus(@Param("status") Integer 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 ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// 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
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
+1
-35
@@ -93,26 +93,6 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "order_position", nullable = false)
|
@Column(name = "order_position", nullable = false)
|
||||||
private Integer orderPosition;
|
private Integer orderPosition;
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
|
|
||||||
*
|
|
||||||
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
|
|
||||||
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
|
|
||||||
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
|
|
||||||
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
|
|
||||||
* (Section 13).
|
|
||||||
*
|
|
||||||
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
|
|
||||||
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
|
|
||||||
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
|
|
||||||
* rule states what it is; nothing has to guess from the shape of its other columns.
|
|
||||||
*/
|
|
||||||
@Column(name = "rule_kind", length = 10)
|
|
||||||
private String ruleKind;
|
|
||||||
|
|
||||||
public static final String KIND_KYC = "KYC";
|
|
||||||
public static final String KIND_AML = "AML";
|
|
||||||
|
|
||||||
protected ClientRiskRule() {}
|
protected ClientRiskRule() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,9 +104,8 @@ public class ClientRiskRule {
|
|||||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||||
final String dormantFlag, final String ruleKind) {
|
final String dormantFlag) {
|
||||||
final ClientRiskRule rule = new ClientRiskRule();
|
final ClientRiskRule rule = new ClientRiskRule();
|
||||||
rule.ruleKind = ruleKind;
|
|
||||||
rule.ruleName = ruleName;
|
rule.ruleName = ruleName;
|
||||||
rule.reason = reason;
|
rule.reason = reason;
|
||||||
rule.riskCategory = riskCategory;
|
rule.riskCategory = riskCategory;
|
||||||
@@ -158,19 +137,6 @@ public class ClientRiskRule {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getRuleKind() {
|
|
||||||
return ruleKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this rule may take part in onboarding risk categorisation. Rows predating the
|
|
||||||
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
|
|
||||||
* into the same table.
|
|
||||||
*/
|
|
||||||
public boolean isKycRule() {
|
|
||||||
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getRuleName() {
|
public String getRuleName() {
|
||||||
return ruleName;
|
return ruleName;
|
||||||
}
|
}
|
||||||
|
|||||||
-106
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-7
@@ -31,8 +31,7 @@ public enum DepositAccountOnClosureType {
|
|||||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
|
||||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
@@ -52,7 +51,6 @@ public enum DepositAccountOnClosureType {
|
|||||||
case 200 -> TRANSFER_TO_SAVINGS;
|
case 200 -> TRANSFER_TO_SAVINGS;
|
||||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||||
case 500 -> PARTIAL_REINVEST;
|
|
||||||
default -> INVALID;
|
default -> INVALID;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,10 +85,6 @@ public enum DepositAccountOnClosureType {
|
|||||||
return this.equals(INVALID);
|
return this.equals(INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isPartialReinvest() {
|
|
||||||
return this.equals(PARTIAL_REINVEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: do we really need this?!?
|
// TODO: do we really need this?!?
|
||||||
public static Object[] integerValues() {
|
public static Object[] integerValues() {
|
||||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||||
|
|||||||
-5
@@ -208,7 +208,6 @@ public final class DepositsApiConstants {
|
|||||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||||
public static final String transferDescriptionParamName = "transferDescription";
|
public static final String transferDescriptionParamName = "transferDescription";
|
||||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
|
||||||
public static final String savingsAccounts = "savingsAccounts";
|
public static final String savingsAccounts = "savingsAccounts";
|
||||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||||
@@ -589,10 +588,6 @@ public final class DepositsApiConstants {
|
|||||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
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;
|
return fixedDepositResponseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -92,9 +92,6 @@ public class SavingsApiConstants {
|
|||||||
public static final String statusParamName = "status";
|
public static final String statusParamName = "status";
|
||||||
public static final String subStatusParamName = "subStatus";
|
public static final String subStatusParamName = "subStatus";
|
||||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
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 clientIdParamName = "clientId";
|
||||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||||
@@ -229,7 +226,6 @@ public class SavingsApiConstants {
|
|||||||
public static final String endDateParamName = "endDate";
|
public static final String endDateParamName = "endDate";
|
||||||
public static final String introducerRequiredParamName = "introducerRequired";
|
public static final String introducerRequiredParamName = "introducerRequired";
|
||||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||||
public static final String shareAccountIdParamName = "shareAccountId";
|
|
||||||
public static final String nominationFacilityParamName = "nominationFacility";
|
public static final String nominationFacilityParamName = "nominationFacility";
|
||||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||||
@@ -258,6 +254,7 @@ public class SavingsApiConstants {
|
|||||||
public static final String neftFacilityParamName = "neftFacility";
|
public static final String neftFacilityParamName = "neftFacility";
|
||||||
public static final String rtgsFacilityParamName = "rtgsFacility";
|
public static final String rtgsFacilityParamName = "rtgsFacility";
|
||||||
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
||||||
|
public static final String withdrawalFacilityParamName = "withdrawalFacility";
|
||||||
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
||||||
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
||||||
|
|
||||||
|
|||||||
-17
@@ -157,8 +157,6 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
private Boolean introducerRequired;
|
private Boolean introducerRequired;
|
||||||
private Boolean shareLinkingEnabled;
|
private Boolean shareLinkingEnabled;
|
||||||
private Long shareAccountId;
|
|
||||||
private String shareAccountNo;
|
|
||||||
private Boolean nominationFacility;
|
private Boolean nominationFacility;
|
||||||
private Boolean dormancyAlert;
|
private Boolean dormancyAlert;
|
||||||
private Boolean isDormancyTrackingActiveFromProduct;
|
private Boolean isDormancyTrackingActiveFromProduct;
|
||||||
@@ -219,21 +217,6 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private BigDecimal interestPosting;
|
private BigDecimal interestPosting;
|
||||||
private BigDecimal overdraftPosting;
|
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,
|
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||||
|
|||||||
+1
-10
@@ -256,16 +256,7 @@ public final class PostingPeriod {
|
|||||||
this.daysInYear = daysInYear;
|
this.daysInYear = daysInYear;
|
||||||
this.compoundingPeriods = compoundingPeriods;
|
this.compoundingPeriods = compoundingPeriods;
|
||||||
|
|
||||||
if (isUserPosting) {
|
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||||
// "Post interest as on D" — a closure, or the same action from the account screen. The
|
|
||||||
// period it covers ends at D-1, because interest is earned up to but not including the
|
|
||||||
// day it is paid, and SavingsHelper closes the period there. The transaction itself
|
|
||||||
// still belongs on D: that is the day the member was credited, the day the branch did
|
|
||||||
// the work, and the day the cash is handed over. Dating it to the period end instead
|
|
||||||
// stamps it a day before the account was even closed, and the ledger then disagrees with
|
|
||||||
// the closure request, the voucher and the passbook — all of which carry D.
|
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
|
||||||
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate();
|
this.dateOfPostingTransaction = periodInterval.endDate();
|
||||||
} else {
|
} else {
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||||
|
|||||||
-4
@@ -797,10 +797,6 @@ public final class SavingsEnumerations {
|
|||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||||
break;
|
break;
|
||||||
case PARTIAL_REINVEST:
|
|
||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
|
||||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return optionData;
|
return optionData;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -77,6 +77,9 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
||||||
private boolean interBankTotalCapFacility = false;
|
private boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility", nullable = false)
|
||||||
|
private boolean withdrawalFacility = false;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc", nullable = false)
|
@Column(name = "deposit_facility_kyc", nullable = false)
|
||||||
private boolean depositFacilityKyc = 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("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
||||||
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
||||||
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").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("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
||||||
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
||||||
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
||||||
|
|||||||
+3
@@ -136,6 +136,9 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility;
|
private Boolean interBankTotalCapFacility;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility")
|
||||||
|
private Boolean withdrawalFacility;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc;
|
private Boolean depositFacilityKyc;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -378,6 +378,7 @@ public class CurrentProductAssembler {
|
|||||||
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
||||||
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
||||||
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").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.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
||||||
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
+6
@@ -75,6 +75,9 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility = false;
|
private Boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility")
|
||||||
|
private Boolean withdrawalFacility = false;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc = false;
|
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 void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
||||||
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
||||||
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
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 Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
||||||
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
||||||
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
||||||
@@ -142,6 +147,7 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
this.neftFacility = other.getNeftFacility();
|
this.neftFacility = other.getNeftFacility();
|
||||||
this.rtgsFacility = other.getRtgsFacility();
|
this.rtgsFacility = other.getRtgsFacility();
|
||||||
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
||||||
|
this.withdrawalFacility = other.getWithdrawalFacility();
|
||||||
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
||||||
this.virtualDebitCard = other.getVirtualDebitCard();
|
this.virtualDebitCard = other.getVirtualDebitCard();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -117,6 +117,7 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
|||||||
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
||||||
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
||||||
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
||||||
|
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
|
||||||
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
||||||
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
||||||
product.setAccountFacilityConfig(facilityConfigMap);
|
product.setAccountFacilityConfig(facilityConfigMap);
|
||||||
|
|||||||
+20
@@ -1466,6 +1466,26 @@ public class LoanProductData implements Serializable {
|
|||||||
this.allowAttributeOverrides = loanProductConfigurableAttributes;
|
this.allowAttributeOverrides = loanProductConfigurableAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy "Account Type Creation" fields, flat and keyed by their form field name.
|
||||||
|
*
|
||||||
|
* Set after construction rather than through a constructor parameter: the main constructor already
|
||||||
|
* takes over a hundred arguments, and threading two more through it would touch every call site for
|
||||||
|
* no benefit. Follows the same pattern as setLoanProductConfigurableAttributes above.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> cbsExtensions;
|
||||||
|
|
||||||
|
/** The legacy Interest Rate Detail grid: one entry per interest type, in form order. */
|
||||||
|
private List<Map<String, Object>> interestRateDetail;
|
||||||
|
|
||||||
|
public void setCbsExtensions(final Map<String, Object> cbsExtensions) {
|
||||||
|
this.cbsExtensions = cbsExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInterestRateDetail(final List<Map<String, Object>> interestRateDetail) {
|
||||||
|
this.interestRateDetail = interestRateDetail;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isIsLinkedToFloatingInterestRates() {
|
public boolean isIsLinkedToFloatingInterestRates() {
|
||||||
return this.isLinkedToFloatingInterestRates;
|
return this.isLinkedToFloatingInterestRates;
|
||||||
}
|
}
|
||||||
|
|||||||
+740
@@ -0,0 +1,740 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy "Account Type Creation" configuration for a loan product.
|
||||||
|
*
|
||||||
|
* Fineract's loan product schema has no home for these fields, and they are specific to this
|
||||||
|
* deployment, so they hang off {@link LoanProduct} 1:1 instead of widening m_product_loan - an
|
||||||
|
* upstream change to that table can then never collide with them.
|
||||||
|
*
|
||||||
|
* Loan account creation reads this at origination time, which is why these are typed columns rather
|
||||||
|
* than a JSON blob: callers get a compile-checked getter and reports get a plain column.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "m_product_loan_cbs_ext")
|
||||||
|
public class LoanProductCbsExt {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "loan_product_id")
|
||||||
|
private Long loanProductId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared primary key: this and {@link #loanProductId} are the same column, so the association is
|
||||||
|
* mapped read-only and {@code loanProductId} is the one that writes it. EclipseLink rejects an
|
||||||
|
* entity with two writable mappings for one field.
|
||||||
|
*/
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
|
||||||
|
private LoanProduct loanProduct;
|
||||||
|
|
||||||
|
// ---- classification & legacy header fields (24) ---------------------
|
||||||
|
|
||||||
|
@Column(name = "product_type_group")
|
||||||
|
private String productTypeGroup;
|
||||||
|
|
||||||
|
@Column(name = "product_type_sub_group")
|
||||||
|
private String productTypeSubGroup;
|
||||||
|
|
||||||
|
@Column(name = "debit_int_calculation")
|
||||||
|
private Boolean debitIntCalculation;
|
||||||
|
|
||||||
|
@Column(name = "separate_penal_interest")
|
||||||
|
private Boolean separatePenalInterest;
|
||||||
|
|
||||||
|
@Column(name = "credit_int_calculation")
|
||||||
|
private Boolean creditIntCalculation;
|
||||||
|
|
||||||
|
@Column(name = "sub_account_type_balance")
|
||||||
|
private Boolean subAccountTypeBalance;
|
||||||
|
|
||||||
|
@Column(name = "final_int_rounding")
|
||||||
|
private Boolean finalIntRounding;
|
||||||
|
|
||||||
|
@Column(name = "rounding_up_to")
|
||||||
|
private Integer roundingUpTo;
|
||||||
|
|
||||||
|
@Column(name = "account_type_code")
|
||||||
|
private String accountTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "micr_transaction_code")
|
||||||
|
private String micrTransactionCode;
|
||||||
|
|
||||||
|
@Column(name = "reserved_ecs_serial_number")
|
||||||
|
private String reservedEcsSerialNumber;
|
||||||
|
|
||||||
|
@Column(name = "reverse_ecs_number")
|
||||||
|
private String reverseEcsNumber;
|
||||||
|
|
||||||
|
@Column(name = "lock_cheque_info_enabled")
|
||||||
|
private Boolean lockChequeInfoEnabled;
|
||||||
|
|
||||||
|
@Column(name = "lock_page_two_info_enabled")
|
||||||
|
private Boolean lockPageTwoInfoEnabled;
|
||||||
|
|
||||||
|
@Column(name = "computerization_date")
|
||||||
|
private LocalDate computerizationDate;
|
||||||
|
|
||||||
|
@Column(name = "cheque_print_description")
|
||||||
|
private String chequePrintDescription;
|
||||||
|
|
||||||
|
@Column(name = "pos_account_type_description")
|
||||||
|
private String posAccountTypeDescription;
|
||||||
|
|
||||||
|
@Column(name = "pfms_account_type")
|
||||||
|
private String pfmsAccountType;
|
||||||
|
|
||||||
|
@Column(name = "renewal_date_add_months")
|
||||||
|
private Integer renewalDateAddMonths;
|
||||||
|
|
||||||
|
@Column(name = "document_renewal_date_add_months")
|
||||||
|
private Integer documentRenewalDateAddMonths;
|
||||||
|
|
||||||
|
@Column(name = "pps_limit")
|
||||||
|
private BigDecimal ppsLimit;
|
||||||
|
|
||||||
|
@Column(name = "is_write_off_account_type")
|
||||||
|
private Boolean isWriteOffAccountType;
|
||||||
|
|
||||||
|
@Column(name = "write_off_account_type_code")
|
||||||
|
private String writeOffAccountTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "renewal_grace_days")
|
||||||
|
private Integer renewalGraceDays;
|
||||||
|
|
||||||
|
// ---- Cheque Book Settings (10) --------------------------------------
|
||||||
|
|
||||||
|
@Column(name = "cheque_book_issue_enabled")
|
||||||
|
private Boolean chequeBookIssueEnabled;
|
||||||
|
|
||||||
|
@Column(name = "cheque_validation_required")
|
||||||
|
private Boolean chequeValidationRequired;
|
||||||
|
|
||||||
|
@Column(name = "is_first_cheque_book_free")
|
||||||
|
private Boolean isFirstChequeBookFree;
|
||||||
|
|
||||||
|
@Column(name = "cheque_printing_account_type")
|
||||||
|
private String chequePrintingAccountType;
|
||||||
|
|
||||||
|
@Column(name = "allow_withdrawal_slip_usage")
|
||||||
|
private Boolean allowWithdrawalSlipUsage;
|
||||||
|
|
||||||
|
@Column(name = "maintain_cheque_book_stock_registry")
|
||||||
|
private Boolean maintainChequeBookStockRegistry;
|
||||||
|
|
||||||
|
@Column(name = "free_cheque_books_per_year")
|
||||||
|
private Integer freeChequeBooksPerYear;
|
||||||
|
|
||||||
|
@Column(name = "allow_credit_by_cheque_number")
|
||||||
|
private Boolean allowCreditByChequeNumber;
|
||||||
|
|
||||||
|
@Column(name = "credit_cheque_validation_required")
|
||||||
|
private Boolean creditChequeValidationRequired;
|
||||||
|
|
||||||
|
@Column(name = "grace_days_for_stock_statement")
|
||||||
|
private Integer graceDaysForStockStatement;
|
||||||
|
|
||||||
|
// ---- Additional Product Settings (37) -------------------------------
|
||||||
|
|
||||||
|
@Column(name = "account_type_active_for_transactions")
|
||||||
|
private Boolean accountTypeActiveForTransactions;
|
||||||
|
|
||||||
|
@Column(name = "sub_type_activity_enabled")
|
||||||
|
private Boolean subTypeActivityEnabled;
|
||||||
|
|
||||||
|
@Column(name = "account_opening_charges")
|
||||||
|
private BigDecimal accountOpeningCharges;
|
||||||
|
|
||||||
|
@Column(name = "maximum_gold_loan_limit")
|
||||||
|
private BigDecimal maximumGoldLoanLimit;
|
||||||
|
|
||||||
|
@Column(name = "last_account_number_opened")
|
||||||
|
private String lastAccountNumberOpened;
|
||||||
|
|
||||||
|
@Column(name = "customer_termination_allowed")
|
||||||
|
private Boolean customerTerminationAllowed;
|
||||||
|
|
||||||
|
@Column(name = "charges_for_cash_credit_above_limit")
|
||||||
|
private BigDecimal chargesForCashCreditAboveLimit;
|
||||||
|
|
||||||
|
@Column(name = "death_interest_gl_account")
|
||||||
|
private String deathInterestGLAccount;
|
||||||
|
|
||||||
|
@Column(name = "starting_ledger_number")
|
||||||
|
private Integer startingLedgerNumber;
|
||||||
|
|
||||||
|
@Column(name = "ending_ledger_number")
|
||||||
|
private Integer endingLedgerNumber;
|
||||||
|
|
||||||
|
@Column(name = "current_ledger_number")
|
||||||
|
private Integer currentLedgerNumber;
|
||||||
|
|
||||||
|
@Column(name = "remittance_account_number")
|
||||||
|
private String remittanceAccountNumber;
|
||||||
|
|
||||||
|
@Column(name = "is_non_resident_indian_account")
|
||||||
|
private Boolean isNonResidentIndianAccount;
|
||||||
|
|
||||||
|
@Column(name = "debit_clearing_gl_account")
|
||||||
|
private String debitClearingGLAccount;
|
||||||
|
|
||||||
|
@Column(name = "missed_call_alert_service_active")
|
||||||
|
private Boolean missedCallAlertServiceActive;
|
||||||
|
|
||||||
|
@Column(name = "standing_instruction_charge_gl_account")
|
||||||
|
private String standingInstructionChargeGLAccount;
|
||||||
|
|
||||||
|
@Column(name = "standing_instruction_debit_charge_skip_option")
|
||||||
|
private String standingInstructionDebitChargeSkipOption;
|
||||||
|
|
||||||
|
@Column(name = "opening_balance_multiplier_for_remittance_charges")
|
||||||
|
private BigDecimal openingBalanceMultiplierForRemittanceCharges;
|
||||||
|
|
||||||
|
@Column(name = "gold_loan_packet_number_with_year_enabled")
|
||||||
|
private Boolean goldLoanPacketNumberWithYearEnabled;
|
||||||
|
|
||||||
|
@Column(name = "account_move_to_inoperative_enabled")
|
||||||
|
private Boolean accountMoveToInoperativeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "generate_gl_diff_entry_enabled")
|
||||||
|
private Boolean generateGlDiffEntryEnabled;
|
||||||
|
|
||||||
|
@Column(name = "sub_type_wise_gl_balance_generation_enabled")
|
||||||
|
private Boolean subTypeWiseGlBalanceGenerationEnabled;
|
||||||
|
|
||||||
|
@Column(name = "validate_before_deleting_debit_entry_enabled")
|
||||||
|
private Boolean validateBeforeDeletingDebitEntryEnabled;
|
||||||
|
|
||||||
|
@Column(name = "rc_file_concept_enabled")
|
||||||
|
private Boolean rcFileConceptEnabled;
|
||||||
|
|
||||||
|
@Column(name = "account_close_zero_balance_in_open_day_process")
|
||||||
|
private Boolean accountCloseZeroBalanceInOpenDayProcess;
|
||||||
|
|
||||||
|
@Column(name = "account_close_zero_balance_at_subledger_tran_entry")
|
||||||
|
private Boolean accountCloseZeroBalanceAtSubledgerTranEntry;
|
||||||
|
|
||||||
|
@Column(name = "limit_enhancement_against_clearing_screen_display_enabled")
|
||||||
|
private Boolean limitEnhancementAgainstClearingScreenDisplayEnabled;
|
||||||
|
|
||||||
|
@Column(name = "advance_interest_parameter_in_ledger_enabled")
|
||||||
|
private Boolean advanceInterestParameterInLedgerEnabled;
|
||||||
|
|
||||||
|
@Column(name = "vehicle_detail_in_master_enabled")
|
||||||
|
private Boolean vehicleDetailInMasterEnabled;
|
||||||
|
|
||||||
|
@Column(name = "deleted_cheque_issue_enabled")
|
||||||
|
private Boolean deletedChequeIssueEnabled;
|
||||||
|
|
||||||
|
@Column(name = "dp_reduce_in_document_renewal_date_enabled")
|
||||||
|
private Boolean dpReduceInDocumentRenewalDateEnabled;
|
||||||
|
|
||||||
|
@Column(name = "dp_reduce_in_renewal_date_enabled")
|
||||||
|
private Boolean dpReduceInRenewalDateEnabled;
|
||||||
|
|
||||||
|
@Column(name = "auto_close_at_day_end_enabled")
|
||||||
|
private Boolean autoCloseAtDayEndEnabled;
|
||||||
|
|
||||||
|
@Column(name = "loan_rate_from_master_enabled")
|
||||||
|
private Boolean loanRateFromMasterEnabled;
|
||||||
|
|
||||||
|
@Column(name = "cheque_short_name")
|
||||||
|
private String chequeShortName;
|
||||||
|
|
||||||
|
@Column(name = "statement_print_enabled")
|
||||||
|
private Boolean statementPrintEnabled;
|
||||||
|
|
||||||
|
@Column(name = "nesl_code")
|
||||||
|
private String neslCode;
|
||||||
|
|
||||||
|
// ---- Interest Related Parameters (53) -------------------------------
|
||||||
|
|
||||||
|
@Column(name = "drawing_power_and_limit_generation_procedure")
|
||||||
|
private String drawingPowerAndLimitGenerationProcedure;
|
||||||
|
|
||||||
|
@Column(name = "advances_interest_calculation_method")
|
||||||
|
private String advancesInterestCalculationMethod;
|
||||||
|
|
||||||
|
@Column(name = "against_interest_calculation_enabled")
|
||||||
|
private Boolean againstInterestCalculationEnabled;
|
||||||
|
|
||||||
|
@Column(name = "day_wise_against_clearing_interest_rounding")
|
||||||
|
private Boolean dayWiseAgainstClearingInterestRounding;
|
||||||
|
|
||||||
|
@Column(name = "interest_calculation_one_fourth_method")
|
||||||
|
private String interestCalculationOneFourthMethod;
|
||||||
|
|
||||||
|
@Column(name = "against_clearing_interest_procedure_type")
|
||||||
|
private String againstClearingInterestProcedureType;
|
||||||
|
|
||||||
|
@Column(name = "consider_debit_closing_amount_for_against_clearing")
|
||||||
|
private Boolean considerDebitClosingAmountForAgainstClearing;
|
||||||
|
|
||||||
|
@Column(name = "interest_rounding_method")
|
||||||
|
private String interestRoundingMethod;
|
||||||
|
|
||||||
|
@Column(name = "penalty_interest_calculation_enabled")
|
||||||
|
private Boolean penaltyInterestCalculationEnabled;
|
||||||
|
|
||||||
|
@Column(name = "penalty_interest_calculation_method")
|
||||||
|
private String penaltyInterestCalculationMethod;
|
||||||
|
|
||||||
|
@Column(name = "penalty_interest_procedure_number")
|
||||||
|
private String penaltyInterestProcedureNumber;
|
||||||
|
|
||||||
|
@Column(name = "overdrawn_interest_calculation_method")
|
||||||
|
private String overdrawnInterestCalculationMethod;
|
||||||
|
|
||||||
|
@Column(name = "calculate_overdrawn_interest_after_renewal")
|
||||||
|
private Boolean calculateOverdrawnInterestAfterRenewal;
|
||||||
|
|
||||||
|
@Column(name = "interest_on_credit_balance")
|
||||||
|
private Boolean interestOnCreditBalance;
|
||||||
|
|
||||||
|
@Column(name = "penalty_interest_date_different_than_regular_interest")
|
||||||
|
private Boolean penaltyInterestDateDifferentThanRegularInterest;
|
||||||
|
|
||||||
|
@Column(name = "fixed_deposit_overdraft_certificate_sort_order")
|
||||||
|
private String fixedDepositOverdraftCertificateSortOrder;
|
||||||
|
|
||||||
|
@Column(name = "use_document_submission_date_for_interest")
|
||||||
|
private Boolean useDocumentSubmissionDateForInterest;
|
||||||
|
|
||||||
|
@Column(name = "loan_management_system_renewal_penalty_active")
|
||||||
|
private Boolean loanManagementSystemRenewalPenaltyActive;
|
||||||
|
|
||||||
|
@Column(name = "minimum_normal_interest_amount")
|
||||||
|
private BigDecimal minimumNormalInterestAmount;
|
||||||
|
|
||||||
|
@Column(name = "minimum_interest_calculation_method")
|
||||||
|
private String minimumInterestCalculationMethod;
|
||||||
|
|
||||||
|
@Column(name = "minimum_against_clearing_interest_amount")
|
||||||
|
private BigDecimal minimumAgainstClearingInterestAmount;
|
||||||
|
|
||||||
|
@Column(name = "limit_increase_upto_interest_amount_in_interest_calculation")
|
||||||
|
private Boolean limitIncreaseUptoInterestAmountInInterestCalculation;
|
||||||
|
|
||||||
|
@Column(name = "minimum_penalty_interest_amount")
|
||||||
|
private BigDecimal minimumPenaltyInterestAmount;
|
||||||
|
|
||||||
|
@Column(name = "days_for_limit_increase_upto_interest_amount")
|
||||||
|
private Integer daysForLimitIncreaseUptoInterestAmount;
|
||||||
|
|
||||||
|
@Column(name = "additional_interest_rate_for_fixed_deposit_overdraft")
|
||||||
|
private BigDecimal additionalInterestRateForFixedDepositOverdraft;
|
||||||
|
|
||||||
|
@Column(name = "consider_dd_payorder_in_against_clearing_interest_calculation")
|
||||||
|
private Boolean considerDdPayorderInAgainstClearingInterestCalculation;
|
||||||
|
|
||||||
|
@Column(name = "grace_days_after_renew_date")
|
||||||
|
private Integer graceDaysAfterRenewDate;
|
||||||
|
|
||||||
|
@Column(name = "apply_undebited_interest_concept_enabled")
|
||||||
|
private Boolean applyUndebitedInterestConceptEnabled;
|
||||||
|
|
||||||
|
@Column(name = "interest_register_print_format_type")
|
||||||
|
private String interestRegisterPrintFormatType;
|
||||||
|
|
||||||
|
@Column(name = "normal_grace_days_after_overdue")
|
||||||
|
private Integer normalGraceDaysAfterOverdue;
|
||||||
|
|
||||||
|
@Column(name = "allow_against_clearing_entry")
|
||||||
|
private Boolean allowAgainstClearingEntry;
|
||||||
|
|
||||||
|
@Column(name = "allow_no_balance_entry")
|
||||||
|
private Boolean allowNoBalanceEntry;
|
||||||
|
|
||||||
|
@Column(name = "fd_od_penalty_interest_combined_with_normal_interest")
|
||||||
|
private Boolean fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest;
|
||||||
|
|
||||||
|
@Column(name = "allow_no_balance_entry_with_password")
|
||||||
|
private Boolean allowNoBalanceEntryWithPassword;
|
||||||
|
|
||||||
|
@Column(name = "undebited_simple_interest_account_wise")
|
||||||
|
private Boolean undebitedSimpleInterestAccountWise;
|
||||||
|
|
||||||
|
@Column(name = "daily_interest_calculation_enabled")
|
||||||
|
private Boolean dailyInterestCalculationEnabled;
|
||||||
|
|
||||||
|
@Column(name = "daily_interest_calculation_start_date")
|
||||||
|
private String dailyInterestCalculationStartDate;
|
||||||
|
|
||||||
|
@Column(name = "non_performing_asset_penal_interest_rate_update")
|
||||||
|
private Boolean nonPerformingAssetPenalInterestRateUpdate;
|
||||||
|
|
||||||
|
@Column(name = "non_performing_asset_penal_interest_rate")
|
||||||
|
private BigDecimal nonPerformingAssetPenalInterestRate;
|
||||||
|
|
||||||
|
@Column(name = "fd_od_penal_interest_rate_update_in_branch_process")
|
||||||
|
private Boolean fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess;
|
||||||
|
|
||||||
|
@Column(name = "add_penal_interest_rate")
|
||||||
|
private BigDecimal addPenalInterestRate;
|
||||||
|
|
||||||
|
@Column(name = "atyp14_simple_interest_enabled")
|
||||||
|
private Boolean atyp14SimpleInterestEnabled;
|
||||||
|
|
||||||
|
@Column(name = "fixed_deposit_overdraft_margin_type")
|
||||||
|
private String fixedDepositOverdraftMarginType;
|
||||||
|
|
||||||
|
@Column(name = "check_master_detail_edited_passing_pending_enabled")
|
||||||
|
private Boolean checkMasterDetailEditedPassingPendingEnabled;
|
||||||
|
|
||||||
|
@Column(name = "credit_debit_interest_calculation_separate_enabled")
|
||||||
|
private Boolean creditDebitInterestCalculationSeparateEnabled;
|
||||||
|
|
||||||
|
@Column(name = "on_time_interest_provision_enabled")
|
||||||
|
private Boolean onTimeInterestProvisionEnabled;
|
||||||
|
|
||||||
|
@Column(name = "check_against_clearing_enabled")
|
||||||
|
private Boolean checkAgainstClearingEnabled;
|
||||||
|
|
||||||
|
@Column(name = "gst_on_penalty_enabled")
|
||||||
|
private Boolean gstOnPenaltyEnabled;
|
||||||
|
|
||||||
|
@Column(name = "penalty_interest_grace_days")
|
||||||
|
private Integer penaltyInterestGraceDays;
|
||||||
|
|
||||||
|
@Column(name = "overdrawn_interest_rate")
|
||||||
|
private BigDecimal overdrawnInterestRate;
|
||||||
|
|
||||||
|
@Column(name = "calculate_daily_limit_enhancement_tod_enabled")
|
||||||
|
private Boolean calculateDailyLimitEnhancementTodEnabled;
|
||||||
|
|
||||||
|
@Column(name = "fixed_deposit_overdraft_multiple_interest_rate_enabled")
|
||||||
|
private Boolean fixedDepositOverdraftMultipleInterestRateEnabled;
|
||||||
|
|
||||||
|
@Column(name = "grace_days_after_overdue_for_moratorium")
|
||||||
|
private Integer graceDaysAfterOverdueForMoratorium;
|
||||||
|
|
||||||
|
// ---- Credit Bureau Settings (CIBIL) (8) ----------------------------
|
||||||
|
|
||||||
|
@Column(name = "cibil_account_type")
|
||||||
|
private String cibilAccountType;
|
||||||
|
|
||||||
|
@Column(name = "cibil_commercial_account_type")
|
||||||
|
private String cibilCommercialAccountType;
|
||||||
|
|
||||||
|
@Column(name = "highmark_account_type")
|
||||||
|
private String highmarkAccountType;
|
||||||
|
|
||||||
|
@Column(name = "highmark_commercial_account_type")
|
||||||
|
private String highmarkCommercialAccountType;
|
||||||
|
|
||||||
|
@Column(name = "experian_account_type")
|
||||||
|
private String experianAccountType;
|
||||||
|
|
||||||
|
@Column(name = "experian_commercial_account_type")
|
||||||
|
private String experianCommercialAccountType;
|
||||||
|
|
||||||
|
@Column(name = "equifax_account_type")
|
||||||
|
private String equifaxAccountType;
|
||||||
|
|
||||||
|
@Column(name = "equifax_commercial_account_type")
|
||||||
|
private String equifaxCommercialAccountType;
|
||||||
|
|
||||||
|
// ---- RBI Compliance & Product Category (6) -------------------------
|
||||||
|
|
||||||
|
@Column(name = "loan_product_category")
|
||||||
|
private String loanProductCategory;
|
||||||
|
|
||||||
|
@Column(name = "penal_charge_method")
|
||||||
|
private String penalChargeMethod;
|
||||||
|
|
||||||
|
@Column(name = "is_floating_rate")
|
||||||
|
private Boolean isFloatingRate;
|
||||||
|
|
||||||
|
@Column(name = "foreclosure_charges_applicable")
|
||||||
|
private Boolean foreclosureChargesApplicable;
|
||||||
|
|
||||||
|
@Column(name = "key_fact_statement_enabled")
|
||||||
|
private Boolean keyFactStatementEnabled;
|
||||||
|
|
||||||
|
@Column(name = "annual_percentage_rate_calculation_enabled")
|
||||||
|
private Boolean annualPercentageRateCalculationEnabled;
|
||||||
|
|
||||||
|
// ---- Channel & Transaction Limits (23) ------------------------------
|
||||||
|
|
||||||
|
@Column(name = "is_mobile_app_enabled")
|
||||||
|
private Boolean isMobileAppEnabled;
|
||||||
|
|
||||||
|
@Column(name = "is_atm_enabled")
|
||||||
|
private Boolean isAtmEnabled;
|
||||||
|
|
||||||
|
@Column(name = "atm_yearly_charge_enabled")
|
||||||
|
private Boolean atmYearlyChargeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "atm_minimum_balance")
|
||||||
|
private BigDecimal atmMinimumBalance;
|
||||||
|
|
||||||
|
@Column(name = "email_active_enabled")
|
||||||
|
private Boolean emailActiveEnabled;
|
||||||
|
|
||||||
|
@Column(name = "is_imps_enabled")
|
||||||
|
private Boolean isImpsEnabled;
|
||||||
|
|
||||||
|
@Column(name = "is_upi_enabled")
|
||||||
|
private Boolean isUpiEnabled;
|
||||||
|
|
||||||
|
@Column(name = "is_pos_enabled")
|
||||||
|
private Boolean isPosEnabled;
|
||||||
|
|
||||||
|
@Column(name = "is_bbps_enabled")
|
||||||
|
private Boolean isBbpsEnabled;
|
||||||
|
|
||||||
|
@Column(name = "inter_bank_limit_per_day")
|
||||||
|
private BigDecimal interBankLimitPerDay;
|
||||||
|
|
||||||
|
@Column(name = "inter_bank_limit_per_transaction")
|
||||||
|
private BigDecimal interBankLimitPerTransaction;
|
||||||
|
|
||||||
|
@Column(name = "neft_rtgs_limit_per_day")
|
||||||
|
private BigDecimal neftRtgsLimitPerDay;
|
||||||
|
|
||||||
|
@Column(name = "neft_rtgs_limit_per_transaction")
|
||||||
|
private BigDecimal neftRtgsLimitPerTransaction;
|
||||||
|
|
||||||
|
@Column(name = "neft_cutoff_limit")
|
||||||
|
private BigDecimal neftCutoffLimit;
|
||||||
|
|
||||||
|
@Column(name = "rtgs_cutoff_limit")
|
||||||
|
private BigDecimal rtgsCutoffLimit;
|
||||||
|
|
||||||
|
@Column(name = "neft_rtgs_minimum_balance")
|
||||||
|
private BigDecimal neftRtgsMinimumBalance;
|
||||||
|
|
||||||
|
@Column(name = "is_charge_required_neft_rtgs_cbs")
|
||||||
|
private Boolean isChargeRequiredNeftRtgsCbs;
|
||||||
|
|
||||||
|
@Column(name = "is_charge_required_neft_rtgs_mobile")
|
||||||
|
private Boolean isChargeRequiredNeftRtgsMobile;
|
||||||
|
|
||||||
|
@Column(name = "imps_limit_per_day")
|
||||||
|
private BigDecimal impsLimitPerDay;
|
||||||
|
|
||||||
|
@Column(name = "imps_limit_per_transaction")
|
||||||
|
private BigDecimal impsLimitPerTransaction;
|
||||||
|
|
||||||
|
@Column(name = "imps_limit_per_month")
|
||||||
|
private BigDecimal impsLimitPerMonth;
|
||||||
|
|
||||||
|
@Column(name = "imps_min_limit")
|
||||||
|
private BigDecimal impsMinLimit;
|
||||||
|
|
||||||
|
@Column(name = "imps_monthly_transaction_count")
|
||||||
|
private Integer impsMonthlyTransactionCount;
|
||||||
|
|
||||||
|
// ---- POS Facility & ATM CSV Settings (10) ---------------------------
|
||||||
|
|
||||||
|
@Column(name = "max_per_transaction_limit_atm")
|
||||||
|
private BigDecimal maxPerTransactionLimitAtm;
|
||||||
|
|
||||||
|
@Column(name = "max_day_transaction_limit_atm")
|
||||||
|
private BigDecimal maxDayTransactionLimitAtm;
|
||||||
|
|
||||||
|
@Column(name = "max_per_transaction_limit_pos")
|
||||||
|
private BigDecimal maxPerTransactionLimitPos;
|
||||||
|
|
||||||
|
@Column(name = "max_day_transaction_limit_pos")
|
||||||
|
private BigDecimal maxDayTransactionLimitPos;
|
||||||
|
|
||||||
|
@Column(name = "max_per_transaction_limit_ecom")
|
||||||
|
private BigDecimal maxPerTransactionLimitEcom;
|
||||||
|
|
||||||
|
@Column(name = "max_day_transaction_limit_ecom")
|
||||||
|
private BigDecimal maxDayTransactionLimitEcom;
|
||||||
|
|
||||||
|
@Column(name = "max_per_transaction_limit_upi")
|
||||||
|
private BigDecimal maxPerTransactionLimitUpi;
|
||||||
|
|
||||||
|
@Column(name = "max_day_transaction_limit_upi")
|
||||||
|
private BigDecimal maxDayTransactionLimitUpi;
|
||||||
|
|
||||||
|
@Column(name = "atm_csv_product_code")
|
||||||
|
private String atmCsvProductCode;
|
||||||
|
|
||||||
|
@Column(name = "atm_csv_type_code")
|
||||||
|
private String atmCsvTypeCode;
|
||||||
|
|
||||||
|
// ---- Auto Interest Post Parameters (4) -----------------------------
|
||||||
|
|
||||||
|
@Column(name = "auto_interest_post_type")
|
||||||
|
private String autoInterestPostType;
|
||||||
|
|
||||||
|
@Column(name = "auto_interest_post_when")
|
||||||
|
private String autoInterestPostWhen;
|
||||||
|
|
||||||
|
@Column(name = "auto_interest_post_fix_day")
|
||||||
|
private Integer autoInterestPostFixDay;
|
||||||
|
|
||||||
|
@Column(name = "auto_last_interest_date")
|
||||||
|
private String autoLastInterestDate;
|
||||||
|
|
||||||
|
// ---- Foreclosure / Early Account Closure (3) -----------------------
|
||||||
|
|
||||||
|
@Column(name = "calculate_early_account_close_charge")
|
||||||
|
private Boolean calculateEarlyAccountCloseCharge;
|
||||||
|
|
||||||
|
@Column(name = "early_account_close_charge")
|
||||||
|
private BigDecimal earlyAccountCloseCharge;
|
||||||
|
|
||||||
|
@Column(name = "early_account_close_period_in_months")
|
||||||
|
private Integer earlyAccountClosePeriodInMonths;
|
||||||
|
|
||||||
|
// ---- Terms step (3) ------------------------------------------------
|
||||||
|
|
||||||
|
@Column(name = "debit_balance_allow")
|
||||||
|
private Boolean debitBalanceAllow;
|
||||||
|
|
||||||
|
@Column(name = "allow_debit_trans_after_due_date")
|
||||||
|
private Boolean allowDebitTransAfterDueDate;
|
||||||
|
|
||||||
|
@Column(name = "branch_level_account_type")
|
||||||
|
private Boolean branchLevelAccountType;
|
||||||
|
|
||||||
|
// ---- legacy cheque / standing instruction / minimum balance charges (24)
|
||||||
|
|
||||||
|
@Column(name = "cheque_book_charge_enabled")
|
||||||
|
private Boolean chequeBookChargeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "cheque_return_charge_enabled")
|
||||||
|
private Boolean chequeReturnChargeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "ic_charge_debit_enabled")
|
||||||
|
private Boolean icChargeDebitEnabled;
|
||||||
|
|
||||||
|
@Column(name = "cash_bundle_charge_enabled")
|
||||||
|
private Boolean cashBundleChargeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "cash_bundle_charge_with_pan_limit")
|
||||||
|
private BigDecimal cashBundleChargeWithPanLimit;
|
||||||
|
|
||||||
|
@Column(name = "per_cheque_entry_charge")
|
||||||
|
private BigDecimal perChequeEntryCharge;
|
||||||
|
|
||||||
|
@Column(name = "per_cheque_entry_service_charge")
|
||||||
|
private BigDecimal perChequeEntryServiceCharge;
|
||||||
|
|
||||||
|
@Column(name = "per_inward_cheque_entry_charge")
|
||||||
|
private BigDecimal perInwardChequeEntryCharge;
|
||||||
|
|
||||||
|
@Column(name = "per_outward_cheque_entry_charge")
|
||||||
|
private BigDecimal perOutwardChequeEntryCharge;
|
||||||
|
|
||||||
|
@Column(name = "creditor_standing_instruction_fail_charge")
|
||||||
|
private BigDecimal creditorStandingInstructionFailCharge;
|
||||||
|
|
||||||
|
@Column(name = "debtor_standing_instruction_fail_charge")
|
||||||
|
private BigDecimal debtorStandingInstructionFailCharge;
|
||||||
|
|
||||||
|
@Column(name = "minimum_balance_checking_type")
|
||||||
|
private String minimumBalanceCheckingType;
|
||||||
|
|
||||||
|
@Column(name = "auto_minimum_balance_charge")
|
||||||
|
private Boolean autoMinimumBalanceCharge;
|
||||||
|
|
||||||
|
@Column(name = "calculate_daily_minimum_balance_charges")
|
||||||
|
private Boolean calculateDailyMinimumBalanceCharges;
|
||||||
|
|
||||||
|
@Column(name = "minimum_balance_charge_enabled")
|
||||||
|
private Boolean minimumBalanceChargeEnabled;
|
||||||
|
|
||||||
|
@Column(name = "minimum_balance_charge_amount")
|
||||||
|
private BigDecimal minimumBalanceChargeAmount;
|
||||||
|
|
||||||
|
@Column(name = "minimum_balance_charge_for_cheque_issue")
|
||||||
|
private BigDecimal minimumBalanceChargeForChequeIssue;
|
||||||
|
|
||||||
|
@Column(name = "minimum_balance_charge_method")
|
||||||
|
private String minimumBalanceChargeMethod;
|
||||||
|
|
||||||
|
@Column(name = "months_after_min_balance_charge_calculate")
|
||||||
|
private Integer monthsAfterMinBalanceChargeCalculate;
|
||||||
|
|
||||||
|
@Column(name = "for_cheque_issue")
|
||||||
|
private BigDecimal forChequeIssue;
|
||||||
|
|
||||||
|
@Column(name = "for_cheque_issue_avg")
|
||||||
|
private BigDecimal forChequeIssueAvg;
|
||||||
|
|
||||||
|
@Column(name = "for_withdrawal_slip")
|
||||||
|
private BigDecimal forWithdrawalSlip;
|
||||||
|
|
||||||
|
@Column(name = "for_withdrawal_slip_avg")
|
||||||
|
private BigDecimal forWithdrawalSlipAvg;
|
||||||
|
|
||||||
|
@Column(name = "calculate_daily_against_clearing_charge")
|
||||||
|
private Boolean calculateDailyAgainstClearingCharge;
|
||||||
|
|
||||||
|
// ---- custom GL account mappings (14) --------------------------------
|
||||||
|
|
||||||
|
@Column(name = "int_receivables_gl_account_id")
|
||||||
|
private Long intReceivablesGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "int_payable_gl_account_id")
|
||||||
|
private Long intPayableGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "deaf_int_payable_gl_account_id")
|
||||||
|
private Long deafIntPayableGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "int_overdue_reserve_account_id")
|
||||||
|
private Long intOverdueReserveAccountId;
|
||||||
|
|
||||||
|
@Column(name = "undebited_interest_account_id")
|
||||||
|
private Long undebitedInterestAccountId;
|
||||||
|
|
||||||
|
@Column(name = "penal_int_cr_gl_account_id")
|
||||||
|
private Long penalIntCrGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "rd_matured_gl_account_id")
|
||||||
|
private Long rdMaturedGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "gl_account_id")
|
||||||
|
private Long glAccountId;
|
||||||
|
|
||||||
|
@Column(name = "ic_charge_gl_account_id")
|
||||||
|
private Long icChargeGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "inoperative_gl_account_id")
|
||||||
|
private Long inoperativeGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "credit_balance_liability_gl_account_id")
|
||||||
|
private Long creditBalanceLiabilityGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "credit_balance_asset_gl_account_id")
|
||||||
|
private Long creditBalanceAssetGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "overdue_accounts_gl_account_id")
|
||||||
|
private Long overdueAccountsGlAccountId;
|
||||||
|
|
||||||
|
@Column(name = "rebate_gl_account_id")
|
||||||
|
private Long rebateGlAccountId;
|
||||||
|
|
||||||
|
public LoanProductCbsExt(final LoanProduct loanProduct) {
|
||||||
|
this.loanProduct = loanProduct;
|
||||||
|
this.loanProductId = loanProduct.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface LoanProductCbsExtRepository extends JpaRepository<LoanProductCbsExt, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A product created before this table existed - or one saved without any legacy field filled in - has
|
||||||
|
* no row here, so callers must handle the empty case rather than assuming one exists.
|
||||||
|
*/
|
||||||
|
Optional<LoanProductCbsExt> findByLoanProductId(Long loanProductId);
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the legacy "Interest Rate Detail" grid on the loan product form.
|
||||||
|
*
|
||||||
|
* The grid is a fixed four rows - Normal Debit, Normal Credit, Against Clearing and Penalty - each
|
||||||
|
* carrying a Normal and a Special min/max rate plus the period they apply over. It is a child table
|
||||||
|
* rather than twenty-four columns on {@link LoanProductCbsExt} because the interest type reads far
|
||||||
|
* better as data than as part of a column name: against_clearing_special_max_rate and its siblings
|
||||||
|
* are almost unreadable, and any new interest type would mean another migration.
|
||||||
|
*
|
||||||
|
* The rows are fixed today, so {@link #INTEREST_TYPES} is the full set and the table carries a unique
|
||||||
|
* constraint on (product, interest type) - a replayed save updates in place instead of doubling the
|
||||||
|
* grid.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "m_product_loan_cbs_interest_rate", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = { "loan_product_id", "interest_type" }, name = "mplcir_unq_product_interest_type") })
|
||||||
|
public class LoanProductCbsInterestRate extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
/** The grid's rows, in the order the form shows them. */
|
||||||
|
public static final String[] INTEREST_TYPES = { "Normal Debit", "Normal Credit", "Against Clearing", "Penalty" };
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "loan_product_id", nullable = false)
|
||||||
|
private LoanProduct loanProduct;
|
||||||
|
|
||||||
|
@Column(name = "interest_type", nullable = false, length = 30)
|
||||||
|
private String interestType;
|
||||||
|
|
||||||
|
@Column(name = "calculation")
|
||||||
|
private Boolean calculation;
|
||||||
|
|
||||||
|
@Column(name = "normal_min_rate", precision = 9, scale = 6)
|
||||||
|
private BigDecimal normalMinRate;
|
||||||
|
|
||||||
|
@Column(name = "normal_max_rate", precision = 9, scale = 6)
|
||||||
|
private BigDecimal normalMaxRate;
|
||||||
|
|
||||||
|
@Column(name = "special_min_rate", precision = 9, scale = 6)
|
||||||
|
private BigDecimal specialMinRate;
|
||||||
|
|
||||||
|
@Column(name = "special_max_rate", precision = 9, scale = 6)
|
||||||
|
private BigDecimal specialMaxRate;
|
||||||
|
|
||||||
|
@Column(name = "period", length = 20)
|
||||||
|
private String period;
|
||||||
|
|
||||||
|
public LoanProductCbsInterestRate(final LoanProduct loanProduct, final String interestType) {
|
||||||
|
this.loanProduct = loanProduct;
|
||||||
|
this.interestType = interestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the officer left this row untouched, so it need not be persisted. */
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return !Boolean.TRUE.equals(this.calculation) && this.normalMinRate == null && this.normalMaxRate == null
|
||||||
|
&& this.specialMinRate == null && this.specialMaxRate == null && (this.period == null || this.period.isBlank());
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface LoanProductCbsInterestRateRepository extends JpaRepository<LoanProductCbsInterestRate, Long> {
|
||||||
|
|
||||||
|
List<LoanProductCbsInterestRate> findByLoanProductIdOrderById(Long loanProductId);
|
||||||
|
|
||||||
|
LoanProductCbsInterestRate findByLoanProductIdAndInterestType(Long loanProductId, String interestType);
|
||||||
|
}
|
||||||
-243
@@ -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";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-112
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-185
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-58
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-176
@@ -25,14 +25,12 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
|
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
||||||
@@ -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.savings.data.SavingsAccountTransactionEnumData;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AccountingProcessorHelper {
|
public class AccountingProcessorHelper {
|
||||||
@@ -92,14 +89,6 @@ public class AccountingProcessorHelper {
|
|||||||
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
||||||
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
|
|
||||||
* posting is routed through. The names are the contract with the product screen; the GL codes
|
|
||||||
* behind them are the bank's choice, so no code is named here.
|
|
||||||
*/
|
|
||||||
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
|
|
||||||
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
|
|
||||||
|
|
||||||
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
||||||
|
|
||||||
public static void setDepositAccountTypeContext(int depositAccountType) {
|
public static void setDepositAccountTypeContext(int depositAccountType) {
|
||||||
@@ -127,10 +116,6 @@ public class AccountingProcessorHelper {
|
|||||||
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
||||||
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
|
|
||||||
// JSON document rather than in acc_product_mapping, so they are read directly. See
|
|
||||||
// namedSavingsGlAccount.
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
public LoanDTO populateLoanDtoFromDTO(
|
public LoanDTO populateLoanDtoFromDTO(
|
||||||
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
||||||
@@ -615,144 +600,6 @@ public class AccountingProcessorHelper {
|
|||||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interest credited to a member, routed through the payable and paid accounts the product names.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Interest On Savings (expense) the bank incurs the cost
|
|
||||||
* Cr Savings Interest Payable (liability) and now owes it
|
|
||||||
*
|
|
||||||
* Dr Savings Interest Payable
|
|
||||||
* Cr Savings Interest Paid the debt is on its way out
|
|
||||||
*
|
|
||||||
* Dr Savings Interest Paid
|
|
||||||
* Cr Savings Control and reaches the member
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The two middle accounts net to nil across the three steps, so the trial balance is unchanged
|
|
||||||
* from the plain two-legged entry; what they add is a reading of how much interest has been
|
|
||||||
* declared but not yet credited, which a single Dr/Cr pair cannot show.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Both are read from the product's Accounting tab and neither is required. A product that names
|
|
||||||
* no paid account collapses the middle step, and one that names no payable falls back to its own
|
|
||||||
* Savings Control - either way the posting degrades to exactly the two-legged entry it was
|
|
||||||
* before, so products configured before these mappings existed are unaffected.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings}
|
|
||||||
* does for the two-legged case.
|
|
||||||
*/
|
|
||||||
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
|
|
||||||
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
|
|
||||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
|
||||||
|
|
||||||
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
|
||||||
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
|
||||||
|
|
||||||
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
|
|
||||||
// where a bank actually picks it. The reserved name in the product's other GL mappings is
|
|
||||||
// consulted only after that, for a product configured before that row was mapped, and the
|
|
||||||
// Savings Control last of all - interest owed is a liability either way.
|
|
||||||
final GLAccount payableAccount = Optional
|
|
||||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
|
||||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
|
||||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
|
||||||
.orElse(controlAccount);
|
|
||||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
|
||||||
.orElse(payableAccount);
|
|
||||||
|
|
||||||
// The interest expense is not raised here. It is recognised as the interest accrues, day by
|
|
||||||
// day, and this posting settles that recognised debt - raising the expense again at payout
|
|
||||||
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
|
|
||||||
// which pays a product's whole membership the same way; a closure reaching this path must
|
|
||||||
// produce the same entries as the nightly run, or the two disagree on what one payment did.
|
|
||||||
//
|
|
||||||
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
|
|
||||||
// product names no paid account both resolve to the payable and this says nothing, so it is
|
|
||||||
// skipped and the payout comes straight out of the payable.
|
|
||||||
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
|
||||||
if (!payableIsPaid) {
|
|
||||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
|
||||||
isReversal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 2: into the member's own balance.
|
|
||||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
|
||||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
|
||||||
isReversal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Dr/Cr pair, with both sides swapped when the transaction is a reversal.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A leg whose two sides are the same account writes nothing. That is not a defensive check but
|
|
||||||
* the mechanism by which the three stages above collapse: a product that names no payable and no
|
|
||||||
* paid account resolves all three to its Savings Control, and what should remain is the plain
|
|
||||||
* {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping
|
|
||||||
* the middle stage by hand was not enough — stage 3 still resolved to Control against Control and
|
|
||||||
* wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's
|
|
||||||
* debits and credits unequal per account and shows up on the trial balance as movement that never
|
|
||||||
* happened. Deciding it here rather than at each call site means every collapse works the same
|
|
||||||
* way, whichever of the two names a product happens to leave unset.
|
|
||||||
*/
|
|
||||||
private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount,
|
|
||||||
final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate,
|
|
||||||
final BigDecimal amount, final boolean isReversal) {
|
|
||||||
if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final GLAccount toDebit = isReversal ? creditAccount : debitAccount;
|
|
||||||
final GLAccount toCredit = isReversal ? debitAccount : creditAccount;
|
|
||||||
createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount);
|
|
||||||
createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A GL account the savings product maps under one of the reserved names on its Accounting tab.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
|
|
||||||
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
|
|
||||||
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
|
|
||||||
* fixed financial account types. The name is matched across all four groups: which group a bank
|
|
||||||
* files it under is a presentation choice, and an account configured under the wrong heading
|
|
||||||
* should still be honoured rather than silently ignored.
|
|
||||||
*
|
|
||||||
* @return the account, or null where the product does not configure this name
|
|
||||||
*/
|
|
||||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
|
||||||
if (savingsProductId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The column holds an empty string on a product saved with no named mappings, and casting
|
|
||||||
// that to jsonb is an error rather than an empty document - so the cast is done inside a
|
|
||||||
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
|
|
||||||
// clause in any order, so guarding at the same query level as the cast would only work by
|
|
||||||
// luck.
|
|
||||||
final List<Long> found = jdbcTemplate.queryForList("""
|
|
||||||
SELECT gl.id
|
|
||||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
|
||||||
FROM m_savings_product
|
|
||||||
WHERE id = ?
|
|
||||||
AND other_gl_mappings IS NOT NULL
|
|
||||||
AND other_gl_mappings <> ''
|
|
||||||
AND other_gl_mappings LIKE '{%') sp
|
|
||||||
CROSS JOIN LATERAL jsonb_array_elements(
|
|
||||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
|
||||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
|
||||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
|
||||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
|
||||||
) AS m(entry)
|
|
||||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
|
||||||
WHERE m.entry ->> 'accountName' = ?
|
|
||||||
LIMIT 1
|
|
||||||
""", Long.class, savingsProductId, accountName);
|
|
||||||
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
||||||
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
||||||
@@ -1394,29 +1241,6 @@ public class AccountingProcessorHelper {
|
|||||||
return accountMapping.getGlAccount();
|
return accountMapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
|
|
||||||
* required. Interest Payable is offered on the product screen but a product configured before it
|
|
||||||
* existed has none, and that has to fall through to the next choice rather than stopping the
|
|
||||||
* account from being closed.
|
|
||||||
*/
|
|
||||||
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
|
||||||
final Long paymentTypeId) {
|
|
||||||
if (savingsProductId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
|
|
||||||
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
|
|
||||||
// which returns null for a row the cash enum does not carry - Interest Payable among them -
|
|
||||||
// and the lookup would die on a NullPointerException instead of the exception meant for it.
|
|
||||||
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
|
|
||||||
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
|
|
||||||
return mapping == null ? null : mapping.getGlAccount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
|
|||||||
+3
-6
@@ -170,12 +170,9 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
|||||||
// Post journal entry if earned interest amount is greater than
|
// Post journal entry if earned interest amount is greater than
|
||||||
// zero
|
// zero
|
||||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
// Routed through the payable and paid accounts the product names, so the society
|
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||||
// can read interest declared but not yet credited as its own balance. Collapses
|
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||||
// that names neither.
|
|
||||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
|
||||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-132
@@ -21,11 +21,9 @@ package org.apache.fineract.accounting.journalentry.service;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.loanproduct.service.LoanEnumerations;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
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.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.NonTransientDataAccessException;
|
import org.springframework.dao.NonTransientDataAccessException;
|
||||||
@@ -140,7 +136,6 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
private final VoucherService voucherService;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@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)
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||||
.withTransactionId(transactionId).with(changes).build();
|
.withTransactionId(transactionId).build();
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
final Throwable throwable = dve.getMostSpecificCause();
|
final Throwable throwable = dve.getMostSpecificCause();
|
||||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
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,
|
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||||
/**
|
/**
|
||||||
@@ -392,9 +328,6 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
||||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
||||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||||
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
|
|
||||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
|
|
||||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,30 +712,15 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
||||||
|
|
||||||
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
||||||
validateNoDenominationManagedCashAccounts(journalEntryCommand);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* revert old journal entries
|
* revert old journal entries
|
||||||
*
|
|
||||||
* A transaction that carries a leg against the denomination-managed cash account (see
|
|
||||||
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
|
|
||||||
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
|
|
||||||
* entry against this same Opening Balances Contra account), so the query above finds it
|
|
||||||
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
|
|
||||||
* leg, would silently erase the cash account's opening balance every time an admin
|
|
||||||
* redefines opening balances for any OTHER account at this office.
|
|
||||||
*/
|
*/
|
||||||
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
|
||||||
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
||||||
officeId);
|
officeId);
|
||||||
for (String transactionId : transactionIdsToBeReversed) {
|
for (String transactionId : transactionIdsToBeReversed) {
|
||||||
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
||||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||||
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
|
|
||||||
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
|
|
||||||
if (touchesDenominationManagedCashAccount) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
revertJournalEntry(journalEntries, "defining opening balance");
|
revertJournalEntry(journalEntries, "defining opening balance");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
@Override
|
||||||
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
||||||
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
||||||
|
|||||||
+4
-6
@@ -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.LoanAmortizationAllocationMappingRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
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.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -68,10 +67,10 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
||||||
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
||||||
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
||||||
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
|
BusinessEventNotifierService businessEventNotifierService) {
|
||||||
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
||||||
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
||||||
businessEventNotifierService, jdbcTemplate);
|
businessEventNotifierService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -99,13 +98,12 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||||
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
LoanTransactionRepository loanTransactionRepository) {
|
||||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
||||||
voucherService);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-9
@@ -301,15 +301,6 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
|||||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: only mapped when the product screen supplies it. Every savings product that
|
|
||||||
// existed before this row was added has none, and a closure on one of those still has to
|
|
||||||
// work — see the closure's own fallback.
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
|
|
||||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element,
|
|
||||||
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId,
|
|
||||||
CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanced accounting mappings
|
// advanced accounting mappings
|
||||||
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
||||||
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
||||||
|
|||||||
-264
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-180
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-60
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-396
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-52
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-88
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-5
@@ -61,11 +61,6 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isMakerCheckerEnabled() {
|
|
||||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||||
|
|||||||
+1
-27
@@ -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.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
|
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
||||||
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
||||||
@@ -164,24 +163,6 @@ public class SecurityConfig {
|
|||||||
// packages here without a Fineract Bearer token; security is enforced by the
|
// packages here without a Fineract Bearer token; security is enforced by the
|
||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||||
// Client account summary, third-party callers — a partner integration calls this
|
|
||||||
// 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,
|
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||||
// no Fineract auth required.
|
// no Fineract auth required.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||||
@@ -427,10 +408,7 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
||||||
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
||||||
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
||||||
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
|
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
|
||||||
// Refuses the handful of accounting features switched off for this installation.
|
|
||||||
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
|
|
||||||
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
|
|
||||||
|
|
||||||
if (loanCOBFilterHelper != null) {
|
if (loanCOBFilterHelper != null) {
|
||||||
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
||||||
@@ -467,10 +445,6 @@ public class SecurityConfig {
|
|||||||
return new RequestResponseFilter();
|
return new RequestResponseFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
|
|
||||||
return new DisabledFeatureApiFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
public LoanCOBApiFilter loanCOBApiFilter() {
|
public LoanCOBApiFilter loanCOBApiFilter() {
|
||||||
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
||||||
}
|
}
|
||||||
|
|||||||
-128
@@ -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\"}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -66,23 +66,6 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
|
|||||||
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
||||||
.retrieveAllJobs(fineractProperties.getNodeId());
|
.retrieveAllJobs(fineractProperties.getNodeId());
|
||||||
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
||||||
// Release any currently_running flag left over from before this boot.
|
|
||||||
//
|
|
||||||
// The flag is set in the scheduler's veto check before a job's body runs and cleared
|
|
||||||
// by the Quartz listener when it returns. Two things leave it set with nothing
|
|
||||||
// running: a JVM that stopped mid-run, and a job started through its own API rather
|
|
||||||
// than through Quartz — no listener is involved there, so nothing ever clears it.
|
|
||||||
//
|
|
||||||
// Once set, the veto silently drops every later trigger: no run-history row, no
|
|
||||||
// error, nothing in the log. The job reads as though it ran and did nothing, and one
|
|
||||||
// dead run mutes it for good. That is how a night of unpaid savings interest goes
|
|
||||||
// unnoticed.
|
|
||||||
//
|
|
||||||
// This loop is the platform coming up, so by definition none of these jobs is
|
|
||||||
// running: whatever the flag says, it is stale. scheduleJob() below already clears
|
|
||||||
// it on the paths it reaches, but not when it throws before that line, and the
|
|
||||||
// saveOrUpdate() after this loop persists whatever we set here either way.
|
|
||||||
jobDetails.setCurrentlyRunning(false);
|
|
||||||
try {
|
try {
|
||||||
jobRegisterService.scheduleJob(jobDetails);
|
jobRegisterService.scheduleJob(jobDetails);
|
||||||
} catch (JobIsNotFoundOrNotEnabledException e) {
|
} catch (JobIsNotFoundOrNotEnabledException e) {
|
||||||
|
|||||||
-109
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-243
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-214
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -29,7 +29,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.DELETE;
|
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -201,20 +200,6 @@ public class OfficesApiResource {
|
|||||||
return toApiJsonSerializer.serialize(result);
|
return toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
|
||||||
@Path("{officeId}")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
|
|
||||||
@ApiResponse(responseCode = "200", description = "OK")
|
|
||||||
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
|
||||||
.deleteOffice(officeId) //
|
|
||||||
.build();
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
return toApiJsonSerializer.serialize(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/external-id/{externalId}")
|
@Path("/external-id/{externalId}")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
-47
@@ -1,47 +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.organisation.office.handler;
|
|
||||||
|
|
||||||
import org.apache.fineract.commands.annotation.CommandType;
|
|
||||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.organisation.office.service.OfficeWritePlatformService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@CommandType(entity = "OFFICE", action = "DELETE")
|
|
||||||
public class DeleteOfficeCommandHandler implements NewCommandSourceHandler {
|
|
||||||
|
|
||||||
private final OfficeWritePlatformService writePlatformService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) {
|
|
||||||
this.writePlatformService = writePlatformService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
|
||||||
|
|
||||||
return this.writePlatformService.deleteOffice(command.entityId(), command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-2
@@ -27,8 +27,6 @@ public interface OfficeWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
|
|
||||||
|
|
||||||
CommandProcessingResult officeTransaction(JsonCommand command);
|
CommandProcessingResult officeTransaction(JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
||||||
|
|||||||
-75
@@ -44,7 +44,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Caching;
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -58,13 +57,6 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||||
private final OfficeTransactionRepository officeTransactionRepository;
|
private final OfficeTransactionRepository officeTransactionRepository;
|
||||||
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
||||||
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
|
|
||||||
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
|
|
||||||
// repository for every one of them just to count rows would be a lot of new wiring for a check
|
|
||||||
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
|
|
||||||
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
|
|
||||||
// long tail that is left is caught by the try/catch around the delete itself.
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -155,73 +147,6 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes an office outright -- distinct from every other office command, which only ever changes
|
|
||||||
* status or fields. There is no "closed" state for an office the way there is for a client or a
|
|
||||||
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
|
|
||||||
* failed with a generic error no matter how empty the office was; the endpoint it called never
|
|
||||||
* existed.
|
|
||||||
*
|
|
||||||
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
|
|
||||||
* structural block), then staff, clients and groups (the day-to-day reason an office still has
|
|
||||||
* anything in it), then any AppUser still homed there. Everything else that references m_office --
|
|
||||||
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
|
|
||||||
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
|
|
||||||
* violation on the delete itself is caught below and turned into one clear message instead. An
|
|
||||||
* office with real banking history behind it is not something this should make easy to remove either
|
|
||||||
* way, checked up front or not.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
@Caching(evict = {
|
|
||||||
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
|
|
||||||
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
|
|
||||||
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
|
|
||||||
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
|
|
||||||
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
|
||||||
|
|
||||||
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
|
|
||||||
"Move or remove its sub-offices before deleting this office.");
|
|
||||||
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
|
|
||||||
"Reassign its staff to another office before deleting this office.");
|
|
||||||
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
|
|
||||||
"Transfer its clients to another office before deleting this office.");
|
|
||||||
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
|
|
||||||
"Transfer its groups to another office before deleting this office.");
|
|
||||||
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
|
|
||||||
"Reassign the users homed at this office before deleting it.");
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.officeRepositoryWrapper.deleteAndFlush(office);
|
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
|
||||||
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
|
||||||
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
|
||||||
} catch (final PersistenceException dve) {
|
|
||||||
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
|
||||||
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
|
||||||
.withCommandId(command.commandId()) //
|
|
||||||
.withEntityId(officeId) //
|
|
||||||
.withOfficeId(officeId) //
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
|
|
||||||
final String plural, final String remedy) {
|
|
||||||
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
|
|
||||||
officeId);
|
|
||||||
if (count != null && count > 0) {
|
|
||||||
final String noun = count == 1 ? singular : plural;
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
|
|
||||||
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
||||||
|
|||||||
+2
-2
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
|
|||||||
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||||
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
||||||
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
||||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
|
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
|
||||||
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
||||||
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
||||||
applicationCurrencyRepository, jdbcTemplate);
|
applicationCurrencyRepository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-16
@@ -357,14 +357,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||||
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||||
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
||||||
accountsSummary.append("sa.deposit_type_enum as depositType, ");
|
accountsSummary.append("sa.deposit_type_enum as depositType ");
|
||||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
|
||||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
|
||||||
// FD into one entry rather than listing every term as a separate account.
|
|
||||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
|
||||||
accountsSummary.append("from m_savings_account sa ");
|
accountsSummary.append("from m_savings_account sa ");
|
||||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
|
||||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||||
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
||||||
@@ -458,15 +453,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
||||||
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
||||||
|
|
||||||
// Null for a savings/current account and for an FD opened before this column existed;
|
|
||||||
// callers fall back to accountNo, so an FD that has never been renewed still reads as
|
|
||||||
// its own FD number.
|
|
||||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
|
||||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
|
||||||
|
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
lastActiveTransactionDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,10 +567,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
|
|
||||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||||
|
|
||||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
|
||||||
null, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-299
@@ -1,299 +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.accountregister.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.sql.Date;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account register for one date: which accounts were opened and which were closed that day, with
|
|
||||||
* each account's balance as it stood on that date alongside what it holds now.
|
|
||||||
*
|
|
||||||
* The dated balance is read from the running balance stamped on the last transaction up to that
|
|
||||||
* date, rather than recomputed from the account's current figure — a current balance tells you
|
|
||||||
* nothing about what the account held three weeks ago.
|
|
||||||
*/
|
|
||||||
@Path("/v1/accountregister")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class AccountRegisterApiResource {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final LocalDate asOn;
|
|
||||||
if (dateParam != null && !dateParam.isBlank()) {
|
|
||||||
try {
|
|
||||||
asOn = LocalDate.parse(dateParam);
|
|
||||||
} catch (final java.time.format.DateTimeParseException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
|
|
||||||
"Date must be in yyyy-MM-dd format.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
}
|
|
||||||
|
|
||||||
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
|
|
||||||
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
|
|
||||||
rows.addAll(depositAccounts(asOn, want));
|
|
||||||
}
|
|
||||||
if (matches(want, "SHARE")) {
|
|
||||||
rows.addAll(shareAccounts(asOn));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
|
|
||||||
// so the totals can never disagree with the list they summarise.
|
|
||||||
int opened = 0;
|
|
||||||
int closed = 0;
|
|
||||||
BigDecimal balanceOnDate = BigDecimal.ZERO;
|
|
||||||
BigDecimal currentBalance = BigDecimal.ZERO;
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
|
|
||||||
opened++;
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
|
|
||||||
closed++;
|
|
||||||
}
|
|
||||||
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
|
|
||||||
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("date", asOn.toString());
|
|
||||||
out.put("section", want);
|
|
||||||
out.put("accounts", rows);
|
|
||||||
out.put("count", rows.size());
|
|
||||||
out.put("openedCount", opened);
|
|
||||||
out.put("closedCount", closed);
|
|
||||||
out.put("totalBalanceOnDate", balanceOnDate);
|
|
||||||
out.put("totalCurrentBalance", currentBalance);
|
|
||||||
return gson.toJson(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean matches(final String want, final String section) {
|
|
||||||
return "ALL".equals(want) || want.equals(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal dec(final Object o) {
|
|
||||||
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
|
|
||||||
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
|
|
||||||
*
|
|
||||||
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
|
|
||||||
* them would make a register of a past day describe a present one.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
|
|
||||||
final Date date = Date.valueOf(asOn);
|
|
||||||
|
|
||||||
final StringBuilder typeFilter = new StringBuilder();
|
|
||||||
if (!"ALL".equals(want)) {
|
|
||||||
final int type = switch (want) {
|
|
||||||
case "SAVINGS" -> 100;
|
|
||||||
case "FD" -> 200;
|
|
||||||
case "RD" -> 300;
|
|
||||||
default -> 0;
|
|
||||||
};
|
|
||||||
if (type > 0) {
|
|
||||||
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
|
|
||||||
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
|
|
||||||
sa.account_balance_derived,
|
|
||||||
c.display_name AS client_name,
|
|
||||||
p.name AS product_name,
|
|
||||||
(SELECT t.running_balance_derived
|
|
||||||
FROM m_savings_account_transaction t
|
|
||||||
WHERE t.savings_account_id = sa.id
|
|
||||||
AND COALESCE(t.is_reversed, false) = false
|
|
||||||
AND t.transaction_date <= ?
|
|
||||||
ORDER BY t.transaction_date DESC, t.id DESC
|
|
||||||
LIMIT 1) AS balance_on_date
|
|
||||||
FROM m_savings_account sa
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_savings_product p ON p.id = sa.product_id
|
|
||||||
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
|
|
||||||
""" + typeFilter + """
|
|
||||||
ORDER BY sa.deposit_type_enum, sa.account_no
|
|
||||||
""", date, date);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
final int depositType = num(r.get("deposit_type_enum"));
|
|
||||||
m.put("accountId", r.get("id"));
|
|
||||||
m.put("accountNo", r.get("account_no"));
|
|
||||||
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
|
|
||||||
m.put("clientName", r.get("client_name"));
|
|
||||||
m.put("productName", r.get("product_name"));
|
|
||||||
m.put("status", savingsStatus(num(r.get("status_enum"))));
|
|
||||||
m.put("openedOn", str(r.get("activatedon_date")));
|
|
||||||
m.put("closedOn", str(r.get("closedon_date")));
|
|
||||||
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
|
|
||||||
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
|
|
||||||
// No transaction up to that date means the account had nothing in it yet, not that the
|
|
||||||
// balance is unknown.
|
|
||||||
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
|
|
||||||
m.put("currentBalance", r.get("account_balance_derived") == null
|
|
||||||
? BigDecimal.ZERO : r.get("account_balance_derived"));
|
|
||||||
out.add(m);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
|
|
||||||
* are individually dated, so the holding on a given date is the shares purchased up to that
|
|
||||||
* date less those redeemed — which is a real dated figure, not the current one relabelled.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
|
|
||||||
final Date date = Date.valueOf(asOn);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
|
|
||||||
sa.total_approved_shares,
|
|
||||||
c.display_name AS client_name,
|
|
||||||
p.name AS product_name,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
|
||||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)) AS share_value,
|
|
||||||
-- Purchases less redemptions dated on or before the register's date. Charge
|
|
||||||
-- rows (type 700) are excluded: an entry fee is not share capital. Only
|
|
||||||
-- approved purchases count (status 300) — a rejected application leaves its
|
|
||||||
-- row behind still flagged active, and counting it credited a member with
|
|
||||||
-- shares they were never issued.
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
|
||||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)
|
|
||||||
AND t.transaction_date <= ?) AS value_on_date,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
|
|
||||||
WHEN t.type_enum = 600 THEN -t.total_shares
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)
|
|
||||||
AND t.transaction_date <= ?) AS shares_on_date
|
|
||||||
FROM m_share_account sa
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_share_product p ON p.id = sa.product_id
|
|
||||||
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
|
|
||||||
ORDER BY sa.account_no
|
|
||||||
""", date, date, date);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
m.put("accountId", r.get("id"));
|
|
||||||
m.put("accountNo", r.get("account_no"));
|
|
||||||
m.put("section", "SHARE");
|
|
||||||
m.put("clientName", r.get("client_name"));
|
|
||||||
m.put("productName", r.get("product_name"));
|
|
||||||
m.put("status", shareStatus(num(r.get("status_enum"))));
|
|
||||||
m.put("openedOn", str(r.get("activated_date")));
|
|
||||||
m.put("closedOn", str(r.get("closed_date")));
|
|
||||||
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
|
|
||||||
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
|
|
||||||
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
|
|
||||||
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
|
|
||||||
// Shares as at the date, not the approved figure — an account can hold fewer than were
|
|
||||||
// approved once some have been redeemed.
|
|
||||||
m.put("sharesHeld", r.get("shares_on_date"));
|
|
||||||
out.add(m);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
|
|
||||||
return dbDate != null && asOn.toString().equals(dbDate.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(final Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int num(final Object o) {
|
|
||||||
return o instanceof Number n ? n.intValue() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String savingsStatus(final int status) {
|
|
||||||
return switch (status) {
|
|
||||||
case 100 -> "SUBMITTED";
|
|
||||||
case 200 -> "APPROVED";
|
|
||||||
case 300 -> "ACTIVE";
|
|
||||||
case 400 -> "WITHDRAWN";
|
|
||||||
case 500 -> "REJECTED";
|
|
||||||
case 600 -> "CLOSED";
|
|
||||||
case 700 -> "TRANSFER IN PROGRESS";
|
|
||||||
case 800 -> "TRANSFER ON HOLD";
|
|
||||||
case 900 -> "PREMATURELY CLOSED";
|
|
||||||
default -> "UNKNOWN";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String shareStatus(final int status) {
|
|
||||||
return switch (status) {
|
|
||||||
case 100 -> "SUBMITTED";
|
|
||||||
case 200 -> "APPROVED";
|
|
||||||
case 300 -> "ACTIVE";
|
|
||||||
case 400 -> "WITHDRAWN";
|
|
||||||
case 500 -> "REJECTED";
|
|
||||||
case 600 -> "CLOSED";
|
|
||||||
default -> "UNKNOWN";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -113,37 +113,6 @@ public interface ShareAccountApiConstants {
|
|||||||
* outstanding balance. */
|
* outstanding balance. */
|
||||||
String linkedloanid_paramname = "linkedLoanId";
|
String linkedloanid_paramname = "linkedLoanId";
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
|
|
||||||
*
|
|
||||||
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
|
|
||||||
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
|
|
||||||
* differ only in how it got there: CASH means the cashier already took it at the counter
|
|
||||||
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
|
|
||||||
*/
|
|
||||||
String fundingsource_paramname = "fundingSource";
|
|
||||||
|
|
||||||
String FUNDING_SOURCE_CASH = "CASH";
|
|
||||||
|
|
||||||
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
|
|
||||||
*
|
|
||||||
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
|
|
||||||
* see which receipt the money they are releasing came in on.
|
|
||||||
*/
|
|
||||||
String advancecollectionid_paramname = "advanceCollectionId";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
|
|
||||||
*
|
|
||||||
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
|
|
||||||
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
|
|
||||||
* rest, and their combined total is what has to cover the shares.
|
|
||||||
*/
|
|
||||||
String additionalcollectionids_paramname = "additionalCollectionIds";
|
|
||||||
|
|
||||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||||
|
|||||||
-14
@@ -25,7 +25,6 @@ import jakarta.ws.rs.GET;
|
|||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -33,7 +32,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -49,7 +47,6 @@ public class AmlCtrReportApiResource {
|
|||||||
private final AmlCtrReportReadPlatformService readPlatformService;
|
private final AmlCtrReportReadPlatformService readPlatformService;
|
||||||
private final AmlCtrReportGenerationService generationService;
|
private final AmlCtrReportGenerationService generationService;
|
||||||
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
|
||||||
private final DefaultToApiJsonSerializer<AmlCtrReportLineData> lineJsonSerializer;
|
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -61,17 +58,6 @@ public class AmlCtrReportApiResource {
|
|||||||
return this.toApiJsonSerializer.serialize(reports);
|
return this.toApiJsonSerializer.serialize(reports);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("lines")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "The reportable lines behind the generated CTRs",
|
|
||||||
description = "Each line resolved to the account, customer, branch and date a FIU-IND filing names. Optional reportId narrows it to one month.")
|
|
||||||
public String retrieveLines(@QueryParam("reportId") final Long reportId) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("generate")
|
@Path("generate")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
-50
@@ -1,50 +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.aml.data;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One reportable line of a monthly CTR, carrying what the FIU-IND submission actually needs rather
|
|
||||||
* than only the ids the line is stored under.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The stored line keeps a client id and a transaction id; a filing needs the account, the customer,
|
|
||||||
* the branch and the date those ids stand for. They are joined here so the screen and the CSV are
|
|
||||||
* built from the report the bank generated, not from a second calculation that could disagree with
|
|
||||||
* it.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A connected-group line has no single transaction behind it - it is a month's worth of smaller
|
|
||||||
* cash movements by one customer that together cross the threshold - so its transaction id, account
|
|
||||||
* and date are null, and {@code connectedGroup} says why.
|
|
||||||
*/
|
|
||||||
public record AmlCtrReportLineData(Long id, Long reportId, String reportMonth, String transactionType, Long transactionId,
|
|
||||||
Long clientId, String clientName, String accountNo, String branchName, LocalDate transactionDate, String direction,
|
|
||||||
BigDecimal amount, boolean connectedGroup,
|
|
||||||
/**
|
|
||||||
* Account facts the Account_Detail file reports. They were being filled in with a constant
|
|
||||||
* opening date of 20/05/2018 and a status of ACTIVE for every row, which is a statement
|
|
||||||
* about the customer's account that nobody had checked.
|
|
||||||
*/
|
|
||||||
LocalDate accountOpenedOn, LocalDate accountClosedOn, String accountStatus,
|
|
||||||
/** Customer identifiers the Account_Person_Relation file reports. */
|
|
||||||
String ckycNumber, String clientExternalId, boolean individual) implements Serializable {}
|
|
||||||
-45
@@ -1,45 +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.aml.data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which way the money moved, for rules written about one direction only.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Without this every rule was matched on amount alone, so a ₹12,00,000 deposit raised
|
|
||||||
* "Frequent low cash WITHDRAWAL" alongside the genuine CTR hit — nine of the ten alerts on that one
|
|
||||||
* transaction were of that kind, and an analyst reading them cannot tell which one is real.
|
|
||||||
*/
|
|
||||||
public enum AmlDirection {
|
|
||||||
|
|
||||||
/** Money into the customer's account: a deposit, or a loan disbursal. */
|
|
||||||
CREDIT,
|
|
||||||
/** Money out of it: a withdrawal, or a loan repayment. */
|
|
||||||
DEBIT,
|
|
||||||
/** Not established — the rule's own direction filter, if it has one, then does not apply. */
|
|
||||||
UNKNOWN;
|
|
||||||
|
|
||||||
public boolean isCredit() {
|
|
||||||
return this == CREDIT;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isDebit() {
|
|
||||||
return this == DEBIT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +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.aml.data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind of customer a rule is written about, for rules whose subject is the customer rather than
|
|
||||||
* the transaction.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Rules like "PEP account monitoring" and "charitable organisation cash withdrawal" name the entity
|
|
||||||
* in their own title, but nothing checked it: each was matched on amount alone and so fired on every
|
|
||||||
* ordinary customer who crossed the amount. Reporting every customer as a PEP is not the cautious
|
|
||||||
* reading - an analyst who sees a PEP alert on a salaried individual learns to ignore PEP alerts,
|
|
||||||
* and the one real politically exposed person is then missed among them.
|
|
||||||
*/
|
|
||||||
public enum AmlSubjectScope {
|
|
||||||
|
|
||||||
/** Politically exposed person, their relative or close associate. */
|
|
||||||
PEP,
|
|
||||||
|
|
||||||
/** Trust, society or other not-for-profit - cash withdrawals from these carry their own rule. */
|
|
||||||
CHARITABLE_ORG,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dealers in precious metal and stones and the other businesses RBI names as higher risk,
|
|
||||||
* where a large transaction is worth reporting that would be routine for anyone else.
|
|
||||||
*/
|
|
||||||
HIGH_RISK_BUSINESS,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A recently opened account. A large transaction through an account that has barely been used
|
|
||||||
* is the classic mule pattern; the same transaction on a decade-old account is not remarkable.
|
|
||||||
*/
|
|
||||||
NEW_ACCOUNT;
|
|
||||||
|
|
||||||
public static AmlSubjectScope fromRule(final String value) {
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return AmlSubjectScope.valueOf(value.trim().toUpperCase());
|
|
||||||
} catch (final IllegalArgumentException e) {
|
|
||||||
// An unrecognised scope must not silently widen the rule back to everyone, so the
|
|
||||||
// caller treats null-from-blank and this case differently.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-56
@@ -19,9 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.aml.data;
|
package org.apache.fineract.portfolio.aml.data;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
|
||||||
@@ -47,43 +45,9 @@ public final class AmlTransactionContext {
|
|||||||
* rules are simply skipped for this context rather than guessed at.
|
* rules are simply skipped for this context rather than guessed at.
|
||||||
*/
|
*/
|
||||||
private final BigDecimal windowAggregateAmount;
|
private final BigDecimal windowAggregateAmount;
|
||||||
/**
|
|
||||||
* Which way the money moved. A rule written about withdrawals must not fire on a deposit, and
|
|
||||||
* "repayment of loan in cash" is not the same event as a disbursal.
|
|
||||||
*/
|
|
||||||
private final AmlDirection direction;
|
|
||||||
/**
|
|
||||||
* How many qualifying transactions make up {@link #windowAggregateAmount}. Structuring is a
|
|
||||||
* pattern of MANY small transactions, so the count is what separates it from one large one:
|
|
||||||
* a single ₹12,00,000 deposit is a CTR, not structuring, however the window total reads.
|
|
||||||
*/
|
|
||||||
private final int windowTransactionCount;
|
|
||||||
/**
|
|
||||||
* Every qualifying amount in the window, so a rule can count only what falls inside its own
|
|
||||||
* band. A single count cannot serve every rule: this client's window held four deposits — three
|
|
||||||
* small ones and one of ₹12,00,000 — which satisfied "three or more" for a structuring rule
|
|
||||||
* whose band none of the four sat in. Structuring is several transactions of a similar size,
|
|
||||||
* and only the amounts themselves can say whether that happened.
|
|
||||||
*/
|
|
||||||
private final List<BigDecimal> windowAmounts;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the account this transaction moved through was opened. The "large transaction on a new
|
|
||||||
* account" rule is about the account rather than the customer, and an old customer can open a
|
|
||||||
* new account. Null where the caller could not establish it, in which case the customer's own
|
|
||||||
* activation date stands in.
|
|
||||||
*/
|
|
||||||
private final LocalDate subjectOpenedOn;
|
|
||||||
|
|
||||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
||||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
|
||||||
this(transactionType, transactionId, client, amount, currencyCode, occurredOn, cash, windowAggregateAmount, AmlDirection.UNKNOWN,
|
|
||||||
0, List.of(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
|
|
||||||
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount, AmlDirection direction,
|
|
||||||
int windowTransactionCount, List<BigDecimal> windowAmounts, LocalDate subjectOpenedOn) {
|
|
||||||
this.transactionType = transactionType;
|
this.transactionType = transactionType;
|
||||||
this.transactionId = transactionId;
|
this.transactionId = transactionId;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
@@ -92,26 +56,6 @@ public final class AmlTransactionContext {
|
|||||||
this.occurredOn = occurredOn;
|
this.occurredOn = occurredOn;
|
||||||
this.cash = cash;
|
this.cash = cash;
|
||||||
this.windowAggregateAmount = windowAggregateAmount;
|
this.windowAggregateAmount = windowAggregateAmount;
|
||||||
this.direction = direction;
|
|
||||||
this.windowTransactionCount = windowTransactionCount;
|
|
||||||
this.windowAmounts = windowAmounts == null ? List.of() : List.copyOf(windowAmounts);
|
|
||||||
this.subjectOpenedOn = subjectOpenedOn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AmlDirection getDirection() {
|
|
||||||
return direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getWindowTransactionCount() {
|
|
||||||
return windowTransactionCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<BigDecimal> getWindowAmounts() {
|
|
||||||
return windowAmounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getSubjectOpenedOn() {
|
|
||||||
return subjectOpenedOn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AmlTransactionType getTransactionType() {
|
public AmlTransactionType getTransactionType() {
|
||||||
|
|||||||
+6
-23
@@ -30,7 +30,6 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.transaction
|
|||||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
|
||||||
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
|
||||||
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
|
||||||
@@ -114,22 +113,12 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
|||||||
}
|
}
|
||||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||||
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
|
||||||
// Same side of the ledger as the transaction being observed: a deposit is not part of a
|
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
|
||||||
// withdrawal pattern, so the two are aggregated separately.
|
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
final List<SavingsAccountTransaction> sameDirection = recent.stream()
|
|
||||||
.filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal()).toList();
|
|
||||||
// Each amount is carried through, not just the total: a rule has to be able to count what
|
|
||||||
// falls inside its own band. Three small deposits plus one of ₹12,00,000 satisfies "three or
|
|
||||||
// more" and a ₹10,00,000 total on the raw figures, which is how one large deposit came to be
|
|
||||||
// reported as structuring.
|
|
||||||
final List<BigDecimal> windowAmounts = sameDirection.stream().map(SavingsAccountTransaction::getAmount).toList();
|
|
||||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
|
|
||||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
|
||||||
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
|
||||||
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate,
|
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||||
depositDirection ? AmlDirection.CREDIT : AmlDirection.DEBIT, sameDirection.size(), windowAmounts,
|
|
||||||
transaction.getSavingsAccount().getActivationDate());
|
|
||||||
try {
|
try {
|
||||||
ruleEvaluationService.evaluate(context);
|
ruleEvaluationService.evaluate(context);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
@@ -145,18 +134,12 @@ public class AmlDomainServiceImpl implements AmlDomainService {
|
|||||||
}
|
}
|
||||||
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
|
||||||
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
|
||||||
final List<LoanTransaction> repayments = recent.stream().filter(LoanTransaction::isRepaymentLikeType).toList();
|
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
|
||||||
final List<BigDecimal> windowAmounts = repayments.stream()
|
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).toList();
|
|
||||||
final BigDecimal windowAggregate = windowAmounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
|
|
||||||
// A repayment takes money from the customer; a disbursal gives it to them. Rules such as
|
|
||||||
// "repayment of loan in cash" are about the first and must not fire on the second.
|
|
||||||
final AmlDirection direction = transaction.isRepaymentLikeType() ? AmlDirection.DEBIT : AmlDirection.CREDIT;
|
|
||||||
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
|
||||||
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
|
||||||
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate, direction,
|
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
|
||||||
repayments.size(), windowAmounts, transaction.getLoan().getApprovedOnDate());
|
|
||||||
try {
|
try {
|
||||||
ruleEvaluationService.evaluate(context);
|
ruleEvaluationService.evaluate(context);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
|
|||||||
-9
@@ -20,17 +20,8 @@ package org.apache.fineract.portfolio.aml.service;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
|
||||||
|
|
||||||
public interface AmlCtrReportReadPlatformService {
|
public interface AmlCtrReportReadPlatformService {
|
||||||
|
|
||||||
List<AmlCtrReportData> retrieveAll();
|
List<AmlCtrReportData> retrieveAll();
|
||||||
|
|
||||||
/**
|
|
||||||
* The reportable lines, resolved to the account, customer and branch a filing names.
|
|
||||||
*
|
|
||||||
* @param reportId
|
|
||||||
* one month's report, or null for every generated month
|
|
||||||
*/
|
|
||||||
List<AmlCtrReportLineData> retrieveLines(Long reportId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-59
@@ -18,16 +18,13 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
|
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -36,40 +33,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
|
||||||
|
|
||||||
/**
|
|
||||||
* Savings transaction type 1 is a deposit and 2 a withdrawal; a CTR names which way the cash
|
|
||||||
* moved, so the line says so rather than leaving the reader to infer it from the amount.
|
|
||||||
*/
|
|
||||||
private static final String LINES_SQL = """
|
|
||||||
SELECT l.id, l.ctr_report_id, r.report_month, l.transaction_type, l.transaction_id,
|
|
||||||
l.client_id, c.display_name AS client_name, l.amount, l.is_connected_group,
|
|
||||||
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
|
|
||||||
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
|
|
||||||
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
|
|
||||||
sa.activatedon_date AS account_opened_on,
|
|
||||||
sa.closedon_date AS account_closed_on,
|
|
||||||
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
|
|
||||||
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
|
|
||||||
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
|
|
||||||
WHEN 800 THEN 'MATURED' END AS account_status,
|
|
||||||
k.ckyc_number AS ckyc_number,
|
|
||||||
-- The customer's own reference for the filing: CKYC number if the record carries
|
|
||||||
-- one, else the CIF the bank knows them by. Never blank, because a relation row
|
|
||||||
-- that identifies nobody cannot be reconciled against the customer card.
|
|
||||||
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
|
|
||||||
COALESCE(c.legal_form_enum, 1) = 1 AS individual
|
|
||||||
FROM m_aml_ctr_report_line l
|
|
||||||
JOIN m_aml_ctr_report r ON r.id = l.ctr_report_id
|
|
||||||
JOIN m_client c ON c.id = l.client_id
|
|
||||||
LEFT JOIN m_office o ON o.id = c.office_id
|
|
||||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
|
||||||
LEFT JOIN m_savings_account_transaction t
|
|
||||||
ON t.id = l.transaction_id AND l.transaction_type = 'SAVINGS'
|
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
|
||||||
""";
|
|
||||||
|
|
||||||
private final AmlCtrReportRepository repository;
|
private final AmlCtrReportRepository repository;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlCtrReportData> retrieveAll() {
|
public List<AmlCtrReportData> retrieveAll() {
|
||||||
@@ -77,29 +41,6 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
|
|||||||
.map(this::mapToData).collect(Collectors.toList());
|
.map(this::mapToData).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<AmlCtrReportLineData> retrieveLines(final Long reportId) {
|
|
||||||
final StringBuilder sql = new StringBuilder(LINES_SQL);
|
|
||||||
final List<Object> args = new ArrayList<>();
|
|
||||||
if (reportId != null) {
|
|
||||||
sql.append(" WHERE l.ctr_report_id = ? ");
|
|
||||||
args.add(reportId);
|
|
||||||
}
|
|
||||||
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
|
|
||||||
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
|
|
||||||
|
|
||||||
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
|
|
||||||
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
|
|
||||||
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
|
|
||||||
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
|
|
||||||
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
|
|
||||||
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
|
|
||||||
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
|
|
||||||
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
|
|
||||||
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
|
|
||||||
rs.getBoolean("individual")), args.toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
private AmlCtrReportData mapToData(AmlCtrReport report) {
|
||||||
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
|
||||||
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
|
||||||
|
|||||||
+1
-4
@@ -30,7 +30,6 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
|
@Transactional
|
||||||
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public class AmlFundTransferEvaluationService {
|
public class AmlFundTransferEvaluationService {
|
||||||
|
|
||||||
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
||||||
|
|||||||
+3
-128
@@ -22,7 +22,6 @@ import java.math.BigDecimal;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlDirection;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
@@ -32,20 +31,11 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
|
||||||
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
|
|
||||||
* transaction that has already succeeded. Joining the caller's transaction would let a failed
|
|
||||||
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
|
|
||||||
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
|
|
||||||
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
|
|
||||||
* contained to AML.
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional
|
||||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||||
|
|
||||||
private static final int MEDIUM_RISK = 2;
|
private static final int MEDIUM_RISK = 2;
|
||||||
@@ -55,7 +45,6 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
private final ClientKycRepository clientKycRepository;
|
private final ClientKycRepository clientKycRepository;
|
||||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||||
private final AmlSubjectScopeResolver subjectScopeResolver;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||||
@@ -83,9 +72,6 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
if (!appliesToThisTransaction(rule, context)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||||
if (threshold == null) {
|
if (threshold == null) {
|
||||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||||
@@ -96,125 +82,14 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
case THRESHOLD:
|
case THRESHOLD:
|
||||||
return context.getAmount().compareTo(threshold) >= 0;
|
return context.getAmount().compareTo(threshold) >= 0;
|
||||||
case STRUCTURING:
|
case STRUCTURING:
|
||||||
return isStructuringHit(rule, context, threshold);
|
|
||||||
case VELOCITY:
|
case VELOCITY:
|
||||||
return isVelocityHit(rule, context, threshold);
|
return context.getWindowAggregateAmount() != null
|
||||||
|
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this rule is about this transaction at all, before any amount is compared.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Everything below was previously unchecked, so a rule matched on amount alone: one ₹12,00,000
|
|
||||||
* cash deposit raised ten alerts, of which one was the genuine CTR hit and nine — "frequent low
|
|
||||||
* cash WITHDRAWAL", "charitable organisation", "PEP account" and the rest — were about
|
|
||||||
* transactions this was not. An analyst who cannot tell which of ten alerts is real stops
|
|
||||||
* reading them, and that is how a real one gets missed.
|
|
||||||
*/
|
|
||||||
private boolean appliesToThisTransaction(AmlTransactionRule rule, AmlTransactionContext context) {
|
|
||||||
// A cash-reporting rule is about physical cash. A transfer or a cheque is traceable by
|
|
||||||
// definition and is not what CTR exists to catch.
|
|
||||||
if (rule.isCashOnly() && !context.isCash()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Direction, where the rule names one. UNKNOWN means the caller could not establish it, so
|
|
||||||
// the filter is not applied rather than guessed at.
|
|
||||||
final String ruleDirection = rule.getDirection();
|
|
||||||
if (ruleDirection != null && context.getDirection() != AmlDirection.UNKNOWN
|
|
||||||
&& !ruleDirection.equalsIgnoreCase(context.getDirection().name())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// And who the rule is about, for rules named after an entity rather than an amount.
|
|
||||||
// PEP_ACCOUNT_MONITORING was raised against an ordinary salaried customer because nothing
|
|
||||||
// here asked whether that customer was politically exposed.
|
|
||||||
return subjectScopeResolver.matches(rule.getSubjectScope(), context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structuring: many transactions each deliberately kept below a reporting threshold, summing
|
|
||||||
* to something that would have been reportable had it been done at once.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Two things separate it from one large transaction, and neither was being checked. The count:
|
|
||||||
* a pattern needs several transactions, so a single deposit is never structuring however large.
|
|
||||||
* And the band: each transaction has to sit in the rule's own min/max window — a ₹12,00,000
|
|
||||||
* deposit is not an instance of "splitting deposits just below ₹50,000", which is exactly what
|
|
||||||
* that rule used to report it as.
|
|
||||||
*/
|
|
||||||
private boolean isStructuringHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
|
||||||
if (context.getWindowAggregateAmount() == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Counted and summed over the rule's own band, not over the window as a whole. A window
|
|
||||||
// holding three ordinary small deposits and one ₹12,00,000 satisfies "three or more" and a
|
|
||||||
// ₹10,00,000 total on the raw figures, and that is how one large deposit came to be
|
|
||||||
// reported as structuring: none of the four was in the band the rule describes.
|
|
||||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
|
||||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Velocity: an unusual number of transactions in a short window, whatever each one is worth.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Like structuring it needs a count — one transaction is not a velocity — but unlike structuring
|
|
||||||
* the individual amounts are not confined to a band, since the concern is the frequency itself.
|
|
||||||
*/
|
|
||||||
private boolean isVelocityHit(AmlTransactionRule rule, AmlTransactionContext context, BigDecimal threshold) {
|
|
||||||
if (context.getWindowAggregateAmount() == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Also confined to the band, for the same reason: "frequent LOW cash deposit" is about a run
|
|
||||||
// of small ones, and a single ₹12,00,000 is not an instance of it however often the account
|
|
||||||
// is used.
|
|
||||||
if (!isWithinRuleBand(rule, context.getAmount())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final List<BigDecimal> inBand = context.getWindowAmounts().stream().filter(a -> isWithinRuleBand(rule, a)).toList();
|
|
||||||
if (!hasEnoughTransactions(rule, inBand.size())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return inBand.stream().reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(threshold) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this transaction sits inside the band the rule describes.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A rule with no band stated matches any amount, which is how these rules behaved before the
|
|
||||||
* columns existed and is what a rule that only names a window total still means.
|
|
||||||
*/
|
|
||||||
private boolean isWithinRuleBand(AmlTransactionRule rule, BigDecimal amount) {
|
|
||||||
final BigDecimal min = rule.getMinLimitAmount();
|
|
||||||
final BigDecimal max = rule.getMaxLimitAmount();
|
|
||||||
if (min != null && amount.compareTo(min) < 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return max == null || amount.compareTo(max) <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether enough transactions have happened in the window to call this a pattern.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A rule that states no minimum keeps the old behaviour; the seeded pattern rules all state 3,
|
|
||||||
* because two large deposits in a month is ordinary business and RBI's own wording is
|
|
||||||
* "numerous".
|
|
||||||
*/
|
|
||||||
private boolean hasEnoughTransactions(AmlTransactionRule rule, int countInBand) {
|
|
||||||
final Integer minimum = rule.getMinTransactionCount();
|
|
||||||
return minimum == null || minimum <= 1 || countInBand >= minimum;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||||
|
|||||||
-136
@@ -1,136 +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.aml.service;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Set;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlSubjectScope;
|
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answers whether the customer behind a transaction is the kind of customer an entity-scoped rule is
|
|
||||||
* written about.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Everything here is read from what the customer record already holds - the client type chosen at
|
|
||||||
* onboarding, the KYC risk category, the account's own age. Nothing is inferred from the amount,
|
|
||||||
* because the amount is what the rule's threshold is already for.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AmlSubjectScopeResolver {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client types that make an account a charitable/not-for-profit one for the purposes of the
|
|
||||||
* cash-withdrawal rule. Matched on the code value's text rather than its id, because ids are
|
|
||||||
* per-tenant and these are seeded per tenant.
|
|
||||||
*/
|
|
||||||
private static final Set<String> CHARITABLE_CLIENT_TYPES = Set.of("TRUST", "SOCIETY", "NGO", "CHARITABLE TRUST");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Businesses RBI names as carrying higher money-laundering risk, where a large transaction is
|
|
||||||
* worth a second look that the same amount elsewhere would not be.
|
|
||||||
*/
|
|
||||||
private static final Set<String> HIGH_RISK_CLIENT_TYPES = Set.of("JEWELLER", "BULLION DEALER", "REAL ESTATE AGENT",
|
|
||||||
"DEALER IN PRECIOUS METALS", "CASINO");
|
|
||||||
|
|
||||||
/** How long an account counts as new. RBI's own mule-account guidance works to a six-month window. */
|
|
||||||
private static final int NEW_ACCOUNT_DAYS = 180;
|
|
||||||
|
|
||||||
private static final int HIGH_RISK_KYC_CATEGORY = 3;
|
|
||||||
|
|
||||||
private final ClientKycRepository clientKycRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a rule carrying this scope should look at this transaction at all.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A rule with no scope is about the transaction rather than the customer and applies to
|
|
||||||
* everyone, which is how these rules behaved before the column existed.
|
|
||||||
*/
|
|
||||||
public boolean matches(final String configuredScope, final AmlTransactionContext context) {
|
|
||||||
final AmlSubjectScope scope = AmlSubjectScope.fromRule(configuredScope);
|
|
||||||
if (scope == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
final Client client = context.getClient();
|
|
||||||
if (client == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return switch (scope) {
|
|
||||||
case PEP -> isPoliticallyExposed(client);
|
|
||||||
case CHARITABLE_ORG -> isClientTypeIn(client, CHARITABLE_CLIENT_TYPES);
|
|
||||||
case HIGH_RISK_BUSINESS -> isClientTypeIn(client, HIGH_RISK_CLIENT_TYPES);
|
|
||||||
case NEW_ACCOUNT -> isNewAccount(context);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There is no dedicated PEP flag on the customer record, so the two places a CBS can carry that
|
|
||||||
* fact are used: an explicit "PEP" client classification, and a High KYC risk category, which is
|
|
||||||
* what a PEP is graded as under the KYC Master Direction. Absent both, the customer is not
|
|
||||||
* treated as a PEP - a rule that cannot identify its subject must raise nothing rather than
|
|
||||||
* report everyone.
|
|
||||||
*/
|
|
||||||
private boolean isPoliticallyExposed(final Client client) {
|
|
||||||
if (codeValueMatches(client.clientClassification() == null ? null : client.clientClassification().getLabel(), "PEP")
|
|
||||||
|| codeValueMatches(client.clientType() == null ? null : client.clientType().getLabel(), "PEP")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return clientKycRepository.findByClientId(client.getId()).map(ClientKyc::getRiskCategoryValue)
|
|
||||||
.filter(category -> category == HIGH_RISK_KYC_CATEGORY).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isClientTypeIn(final Client client, final Set<String> wanted) {
|
|
||||||
final String type = client.clientType() == null ? null : client.clientType().getLabel();
|
|
||||||
final String classification = client.clientClassification() == null ? null : client.clientClassification().getLabel();
|
|
||||||
return containsIgnoreCase(wanted, type) || containsIgnoreCase(wanted, classification);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measured from the customer's activation date against the business date, not the wall clock,
|
|
||||||
* so a back-dated test and a back-dated correction both read the age the books read.
|
|
||||||
*/
|
|
||||||
private boolean isNewAccount(final AmlTransactionContext context) {
|
|
||||||
final LocalDate openedOn = context.getSubjectOpenedOn() != null ? context.getSubjectOpenedOn()
|
|
||||||
: context.getClient().getActivationDate();
|
|
||||||
if (openedOn == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return openedOn.plusDays(NEW_ACCOUNT_DAYS).isAfter(DateUtils.getBusinessLocalDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean containsIgnoreCase(final Set<String> wanted, final String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final String normalised = value.trim().toUpperCase();
|
|
||||||
return wanted.contains(normalised);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean codeValueMatches(final String value, final String token) {
|
|
||||||
return value != null && value.trim().toUpperCase().contains(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-4
@@ -64,13 +64,9 @@ public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationServ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moves the re-KYC due date onto the new category's cycle as well, so the customer is
|
|
||||||
// called back in on the schedule the escalated rating requires.
|
|
||||||
kyc.escalateRiskCategory(nextCategory);
|
kyc.escalateRiskCategory(nextCategory);
|
||||||
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
|
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
|
||||||
context.authenticatedUser(), LocalDateTime.now()));
|
context.authenticatedUser(), LocalDateTime.now()));
|
||||||
log.info("Client {} auto-escalated from risk category {} to {} on the {}rd cumulative STR case; re-KYC now due {}",
|
|
||||||
client.getId(), previousCategory, nextCategory, cumulativeCases, kyc.getExpiryDate());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Integer nextCategory(Integer currentCategory) {
|
private Integer nextCategory(Integer currentCategory) {
|
||||||
|
|||||||
-1249
File diff suppressed because it is too large
Load Diff
+1
-18
@@ -53,28 +53,15 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
// Gated like every sibling register in this flow (CREATE_CASHTRANSFER,
|
|
||||||
// CREATE_CASHSETTLEMENT, CREATE_CASHINTERCASHIER). Cash exchange had no check at all, so any
|
|
||||||
// authenticated user -- not just a cashier -- could write legs that move a drawer's
|
|
||||||
// denomination position. Permission seeded and granted to the existing cash-handling roles by
|
|
||||||
// migration 0539.
|
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHEXCHANGE");
|
|
||||||
|
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
|
||||||
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
|
||||||
: null;
|
: null;
|
||||||
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
|
||||||
// Defaulted, never left null: every as-of-date position query filters
|
|
||||||
// "ce.exchange_date <= CAST(? AS DATE)", which is false for NULL, so a null-dated exchange
|
|
||||||
// vanished from the cashier position and reconciliation reports while still counting in the
|
|
||||||
// undated stock query (DenominationBreakdownWriter.getCashierNetPosition). The drawer then
|
|
||||||
// read as holding the notes it had given away and none of the ones it had taken in. The
|
|
||||||
// sibling registers (cash transfer, settlement) already default the same way.
|
|
||||||
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
|
||||||
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
? LocalDate.parse(json.get("exchangeDate").getAsString())
|
||||||
: DateUtils.getBusinessLocalDate();
|
: null;
|
||||||
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
||||||
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
||||||
? json.get("customerIdProof").getAsString() : null;
|
? json.get("customerIdProof").getAsString() : null;
|
||||||
@@ -166,10 +153,6 @@ public class CashExchangeApiResource {
|
|||||||
@POST
|
@POST
|
||||||
@Path("{id}/reverse")
|
@Path("{id}/reverse")
|
||||||
public String reverse(@PathParam("id") Long id, String body) {
|
public String reverse(@PathParam("id") Long id, String body) {
|
||||||
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
|
|
||||||
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
|
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
|
|
||||||
|
|
||||||
CashExchange exchange = repository.findById(id)
|
CashExchange exchange = repository.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
||||||
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
||||||
|
|||||||
+15
-178
@@ -54,12 +54,7 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -72,28 +67,9 @@ import com.google.common.base.Splitter;
|
|||||||
@Component
|
@Component
|
||||||
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@lombok.extern.slf4j.Slf4j
|
|
||||||
public class CashierDepositRequestApiResource {
|
public class CashierDepositRequestApiResource {
|
||||||
|
|
||||||
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
|
||||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
|
||||||
|
|
||||||
// The temporary txn_id a pending deposit's denomination rows are tagged with (see
|
|
||||||
// savePendingDenomination) is this request's own id -- but m_savings_txn_denomination.txn_id has
|
|
||||||
// no reference-type discriminator, and a real m_savings_account_transaction id is drawn from the
|
|
||||||
// exact same small-integer range. Request #5 and transaction #5 coexisting is the ordinary case,
|
|
||||||
// not a rare one, so approve()'s "WHERE txn_id = <this request's id>" UPDATE (and reject()'s
|
|
||||||
// DELETE) would silently rewrite or erase an unrelated real transaction's own denomination
|
|
||||||
// breakdown whenever the two ids happened to match. Offsetting the temporary id into a range no
|
|
||||||
// real transaction id will reach in this system's lifetime makes every such match unambiguous,
|
|
||||||
// without a schema change. Kept well under CashReportApiResource's own implausible-millisecond-id
|
|
||||||
// band (10^12) so a request still sitting PENDING continues to count in cashier-position reports
|
|
||||||
// -- the cash is physically in the drawer already, approval or not.
|
|
||||||
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final KycRestrictionGuard kycRestrictionGuard;
|
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -148,7 +124,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw apiError(400, "Amount must be greater than zero");
|
throw apiError(400, "Amount must be greater than zero");
|
||||||
}
|
}
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
|
|
||||||
validateDenominationTotal(denominationData, amount);
|
validateDenominationTotal(denominationData, amount);
|
||||||
validateDdLimit(ddFaceValue);
|
validateDdLimit(ddFaceValue);
|
||||||
|
|
||||||
@@ -186,7 +161,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// reject() will delete these records so the vault position is reversed.
|
// reject() will delete these records so the vault position is reversed.
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
||||||
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
|
currentUser.getId(), currentUser.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||||
@@ -419,20 +394,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
txnId = isCurrentAccount
|
txnId = isCurrentAccount
|
||||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
if (!isCurrentAccount && txnId != null) {
|
|
||||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
|
||||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
|
||||||
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
|
||||||
// this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly
|
|
||||||
// best-effort: the deposit approval this whole endpoint exists for must never depend on
|
|
||||||
// it, only benefit from it when it works.
|
|
||||||
try {
|
|
||||||
placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id,
|
|
||||||
savingsAccountId, e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link voucher to the created savings txn and stamp voucher_no on it.
|
// Link voucher to the created savings txn and stamp voucher_no on it.
|
||||||
@@ -445,10 +406,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip, recorded now rather than at submission: which accounts the money
|
|
||||||
// actually moved between is only settled once the request is approved and posted.
|
|
||||||
addDepositVoucherLines(record, savingsAccountId, amount);
|
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -469,15 +426,13 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Denomination was already saved at submit time (cashier physically received cash then).
|
// Denomination was already saved at submit time (cashier physically received cash then).
|
||||||
// Now update the temporary txn_id (offset request id -- see PENDING_DEPOSIT_TXN_ID_OFFSET) to
|
// Now update the temporary txn_id (request id) to the real Fineract transaction id.
|
||||||
// the real Fineract transaction id. parseTxnIdToLong handles both decimal (savings/current)
|
// parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
|
||||||
// and hex (GL journal entry).
|
|
||||||
if (txnId != null) {
|
if (txnId != null) {
|
||||||
final Long realTxnId = parseTxnIdToLong(txnId);
|
final Long realTxnId = parseTxnIdToLong(txnId);
|
||||||
if (realTxnId != null) {
|
if (realTxnId != null) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId,
|
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id);
|
||||||
id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +484,9 @@ public class CashierDepositRequestApiResource {
|
|||||||
throw apiError(409, "This deposit request was already actioned by another manager.");
|
throw apiError(409, "This deposit request was already actioned by another manager.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse denomination — cashier physically returned cash to customer on rejection. The
|
// Reverse denomination — cashier physically returned cash to customer on rejection.
|
||||||
// temporary denomination records (saved at submit time with txn_id = offset request id -- see
|
// The temporary denomination records (saved at submit time with txn_id = request id) are removed.
|
||||||
// PENDING_DEPOSIT_TXN_ID_OFFSET) are removed.
|
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id);
|
||||||
jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET);
|
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||||
}
|
}
|
||||||
@@ -586,62 +540,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the two sides of an approved deposit on its voucher.
|
|
||||||
*
|
|
||||||
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
|
|
||||||
* credited — a member's savings account, or a GL account when the deposit was raised against
|
|
||||||
* one. The credited side is read from the request, because that is what the cashier actually
|
|
||||||
* filled in and what the member signed for.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the approval. The deposit is already posted and the member's
|
|
||||||
* balance already raised; refusing that over its slip would take a correct transaction back for
|
|
||||||
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
|
|
||||||
*/
|
|
||||||
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
|
||||||
final String voucherNo = (String) record.get("voucher_no");
|
|
||||||
if (voucherNo == null || voucherNo.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
|
||||||
final String accountNo = (String) record.get("account_no");
|
|
||||||
|
|
||||||
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
|
|
||||||
// request named; for a member deposit it is the cashier's own till, which the deposit
|
|
||||||
// register does not carry a GL id for — the line still names it, without an id.
|
|
||||||
final Object contraObj = record.get("contra_gl_id");
|
|
||||||
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
|
|
||||||
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
|
|
||||||
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
|
|
||||||
(String) contra.get("gl_code"), amount);
|
|
||||||
|
|
||||||
final VoucherLine credit;
|
|
||||||
if (isGl) {
|
|
||||||
final Object glObj = record.get("gl_account_id");
|
|
||||||
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
|
|
||||||
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
|
||||||
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
|
||||||
} else {
|
|
||||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
voucherService.addLines(voucherNo, List.of(debit, credit));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
|
||||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
|
||||||
if (glAccountId == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
|
||||||
.orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||||
@@ -684,64 +582,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The deposit register raised its own voucher at submission and attaches the till and
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// member sides to it on approval, so the savings command must not raise a second one over
|
|
||||||
// the same money -- exactly as the GL branch of this register already does below. Without
|
|
||||||
// this, one deposit appears twice on the cashier's received report and doubles the day's
|
|
||||||
// cash taken in.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
|
||||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
|
||||||
* until the instrument clears rather than let it sit withdrawable, so this places an automatic
|
|
||||||
* Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and
|
|
||||||
* electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the
|
|
||||||
* spot, and an electronic transfer has already settled by the time it's entered here.
|
|
||||||
*/
|
|
||||||
private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount,
|
|
||||||
final String txnDateStr) {
|
|
||||||
if (paymentTypeId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final Long ptId = ((Number) paymentTypeId).longValue();
|
|
||||||
// m_payment_type's display name column is "value", not "name" (the mistake that broke this
|
|
||||||
// in testing) -- name() collisions with other tables made this easy to get wrong once and not
|
|
||||||
// notice until it actually ran against the real schema.
|
|
||||||
final List<String> names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?",
|
|
||||||
(rs, rowNum) -> rs.getString("value"), ptId);
|
|
||||||
if (names.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final String name = names.get(0).toLowerCase();
|
|
||||||
final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd")
|
|
||||||
|| name.contains("demand draft");
|
|
||||||
if (!isInstrumentDeposit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
|
||||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
|
||||||
payload.addProperty("transactionDate", date);
|
|
||||||
payload.addProperty("transactionAmount", amount);
|
|
||||||
payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit");
|
|
||||||
payload.addProperty("holdCategory", "CLEARING");
|
|
||||||
payload.addProperty("lienAllowed", false);
|
|
||||||
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
|
||||||
.holdAmount(savingsAccountId)
|
|
||||||
.withJson(payload.toString())
|
|
||||||
.build();
|
|
||||||
commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String executeCurrentAccountDeposit(
|
private String executeCurrentAccountDeposit(
|
||||||
final Long currentAccountId, final BigDecimal amount,
|
final Long currentAccountId, final BigDecimal amount,
|
||||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||||
@@ -766,11 +610,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Suppressed for the same reason as the savings path above. Current accounts raise no
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// voucher of their own today, so this changes nothing now; it is here so that the day one
|
|
||||||
// is added, this register does not silently start double-counting again.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,10 +685,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The deposit register raises its own voucher, naming the member and their account; the
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// journal command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,7 +699,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||||
private void savePendingDenomination(final String denominationData,
|
private void savePendingDenomination(final String denominationData,
|
||||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
final Long requestId, final Long savingsAccountId, final String accountNo,
|
||||||
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
|
final Long clientId, final Long cashierId, final String cashierUsername) {
|
||||||
final JsonObject denomMap;
|
final JsonObject denomMap;
|
||||||
try {
|
try {
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
@@ -892,12 +729,12 @@ public class CashierDepositRequestApiResource {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId,
|
requestId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-87
@@ -23,14 +23,12 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -46,10 +44,6 @@ public class CashInterCashierApiResource {
|
|||||||
private final CashInterCashierDetailRepository detailRepository;
|
private final CashInterCashierDetailRepository detailRepository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
// The same figure the Cashier Position screen shows, so a cashier cannot hand on notes the
|
|
||||||
// report says they are not holding -- CashSettlementApiResource depends on it the same way.
|
|
||||||
private final CashReportApiResource cashReportApiResource;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -91,21 +85,6 @@ public class CashInterCashierApiResource {
|
|||||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
|
||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
|
|
||||||
// stock and settlement validates the cashier's; this register validated neither, so a cashier
|
|
||||||
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
|
|
||||||
// for ever, on every future date: the receiver's position, reconciliation and settlement
|
|
||||||
// allowance all rise by ₹1,00,000 of notes that do not exist, and the sender's reads
|
|
||||||
// -195x₹500 with nothing to net it back.
|
|
||||||
validateCashierHoldsDenominations(fromCashierId, denominations, transferDate);
|
|
||||||
|
|
||||||
// Both cashiers must sit under the same office. Nothing stopped a cross-branch inter-cashier
|
|
||||||
// transfer, which moves physical cash between two branches with no vault leg on either side
|
|
||||||
// -- so one branch's day-close is short and the other's is over, with no record of a transfer
|
|
||||||
// between them anywhere in either branch's register.
|
|
||||||
validateSameOffice(fromCashierId, toCashierId);
|
|
||||||
|
|
||||||
final BigDecimal total = sumTotal(denominations);
|
final BigDecimal total = sumTotal(denominations);
|
||||||
|
|
||||||
CashInterCashier header = new CashInterCashier();
|
CashInterCashier header = new CashInterCashier();
|
||||||
@@ -227,72 +206,6 @@ public class CashInterCashierApiResource {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Same check, same source of truth and same message shape as
|
|
||||||
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
|
|
||||||
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
|
|
||||||
* position is left alone rather than refused, matching the settlement path: a cashier with no
|
|
||||||
* recorded position yet is a setup state, not an overdraft.
|
|
||||||
*/
|
|
||||||
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
|
||||||
final LocalDate transferDate) {
|
|
||||||
|
|
||||||
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
|
|
||||||
transferDate.toString());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
|
||||||
if (heldRows == null || heldRows.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<Long, Long> held = new java.util.HashMap<>();
|
|
||||||
for (final Map<String, Object> row : heldRows) {
|
|
||||||
final Object id = row.get("denominationId");
|
|
||||||
final Object count = row.get("count");
|
|
||||||
if (id instanceof Number && count instanceof Number) {
|
|
||||||
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final JsonElement el : denominations) {
|
|
||||||
final JsonObject item = el.getAsJsonObject();
|
|
||||||
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0;
|
|
||||||
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long denominationId = item.get("denominationId").getAsLong();
|
|
||||||
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
|
||||||
? item.get("denominationValue").getAsInt() : 0;
|
|
||||||
final long available = held.getOrDefault(denominationId, 0L);
|
|
||||||
if (count > available) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.insufficient.cashier.denomination",
|
|
||||||
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
|
|
||||||
+ ", transferring " + count + ". Check the Cashier Position report first.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Both cashiers must belong to the same office, via their own teller. */
|
|
||||||
private void validateSameOffice(final Long fromCashierId, final Long toCashierId) {
|
|
||||||
final List<Map<String, Object>> offices = jdbcTemplate.queryForList("""
|
|
||||||
SELECT c.id AS cashier_id, t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
|
||||||
WHERE c.id IN (?, ?)
|
|
||||||
""", fromCashierId, toCashierId);
|
|
||||||
if (offices.size() < 2) {
|
|
||||||
// One of them has no teller/office resolvable -- leave it to the existing party checks
|
|
||||||
// rather than inventing a new failure here.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final Object first = offices.get(0).get("office_id");
|
|
||||||
final Object second = offices.get(1).get("office_id");
|
|
||||||
if (first != null && second != null && !first.equals(second)) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cross.office",
|
|
||||||
"Both cashiers must be in the same branch. Cash moving between branches has to go through"
|
|
||||||
+ " each branch's own vault, not a direct cashier-to-cashier transfer.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveDetails(Long interCashierId, JsonArray items) {
|
private void saveDetails(Long interCashierId, JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
|
|||||||
+1
-39
@@ -28,7 +28,6 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
|
|||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -90,7 +89,6 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'DEPOSIT' "
|
+ "WHERE std.txn_type = 'DEPOSIT' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
@@ -120,7 +118,6 @@ public class CashReconciliationApiResource {
|
|||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' "
|
||||||
+ "AND std.txn_date <= CAST(? AS DATE) "
|
+ "AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
@@ -262,14 +259,6 @@ public class CashReconciliationApiResource {
|
|||||||
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
|
||||||
? json.getAsJsonArray("denominations") : new JsonArray();
|
? json.getAsJsonArray("denominations") : new JsonArray();
|
||||||
|
|
||||||
// Expected counts come from the server's own computation, never from the request body.
|
|
||||||
// Reading the client's expectedCount made the whole reconciliation self-certifying: a cashier
|
|
||||||
// Rs 40,000 short could post expectedCount == physicalCount on every row, and the record
|
|
||||||
// stored variance 0 / status OK. That row then satisfied the mandatory reconciliation
|
|
||||||
// precondition on settlement, so the shortage never surfaced anywhere in the system. The
|
|
||||||
// physical count is the cashier's to declare; what the books expect is not.
|
|
||||||
final Map<Long, Long> serverExpected = expectedCountsByDenomination(cashierId, reconDate);
|
|
||||||
|
|
||||||
BigDecimal expectedTotal = BigDecimal.ZERO;
|
BigDecimal expectedTotal = BigDecimal.ZERO;
|
||||||
BigDecimal physicalTotal = BigDecimal.ZERO;
|
BigDecimal physicalTotal = BigDecimal.ZERO;
|
||||||
List<CashReconciliationDetail> details = new ArrayList<>();
|
List<CashReconciliationDetail> details = new ArrayList<>();
|
||||||
@@ -277,10 +266,7 @@ public class CashReconciliationApiResource {
|
|||||||
for (JsonElement el : denominations) {
|
for (JsonElement el : denominations) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
|
||||||
final Long denomIdForExpected = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
|
||||||
? item.get("denominationId").getAsLong() : null;
|
|
||||||
int expectedCount = denomIdForExpected != null
|
|
||||||
? serverExpected.getOrDefault(denomIdForExpected, 0L).intValue() : 0;
|
|
||||||
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
|
||||||
int varianceCount = physicalCount - expectedCount;
|
int varianceCount = physicalCount - expectedCount;
|
||||||
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
|
||||||
@@ -362,30 +348,6 @@ public class CashReconciliationApiResource {
|
|||||||
rs.getLong("total_count")
|
rs.getLong("total_count")
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The server's own expected note/coin counts for a cashier as of a date, keyed by denomination
|
|
||||||
* id. Delegates to {@link #getExpected} rather than re-deriving the eight legs: two
|
|
||||||
* implementations of "what should this drawer hold" drift apart, and the one place that matters
|
|
||||||
* is the record a settlement is later allowed on the strength of.
|
|
||||||
*/
|
|
||||||
private Map<Long, Long> expectedCountsByDenomination(final Long cashierId, final LocalDate asOfDate) {
|
|
||||||
final Map<Long, Long> expected = new java.util.HashMap<>();
|
|
||||||
final JsonObject computed = JsonParser.parseString(getExpected(cashierId, asOfDate.toString())).getAsJsonObject();
|
|
||||||
if (!computed.has("denominations") || !computed.get("denominations").isJsonArray()) {
|
|
||||||
return expected;
|
|
||||||
}
|
|
||||||
for (final JsonElement el : computed.getAsJsonArray("denominations")) {
|
|
||||||
final JsonObject row = el.getAsJsonObject();
|
|
||||||
if (!row.has("denominationId") || row.get("denominationId").isJsonNull()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long count = row.has("expectedCount") && !row.get("expectedCount").isJsonNull()
|
|
||||||
? row.get("expectedCount").getAsLong() : 0L;
|
|
||||||
expected.merge(row.get("denominationId").getAsLong(), count, Long::sum);
|
|
||||||
}
|
|
||||||
return expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
|
||||||
for (Object[] row : rows) {
|
for (Object[] row : rows) {
|
||||||
Long denomId = (Long) row[0];
|
Long denomId = (Long) row[0];
|
||||||
|
|||||||
+32
-781
@@ -13,11 +13,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -26,7 +23,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
@Path("/v1/cashreports")
|
@Path("/v1/cashreports")
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class CashReportApiResource {
|
public class CashReportApiResource {
|
||||||
|
|
||||||
@@ -34,566 +30,42 @@ public class CashReportApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VaultPositionService vaultPositionService;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
/**
|
|
||||||
* Excludes counter breakdowns that must not count towards a drawer.
|
|
||||||
*
|
|
||||||
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
|
|
||||||
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
|
|
||||||
* breakdown behind and went on inflating the drawer for good. Every other leg of the cash
|
|
||||||
* position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against
|
|
||||||
* its own reversal flag; this is the same guard for the counter, read off the transaction the
|
|
||||||
* breakdown belongs to.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Two things are excluded, and they are different failures:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>a breakdown whose transaction is reversed -- the money came back, so the notes did too;
|
|
||||||
* <li>a breakdown keyed by a millisecond timestamp rather than a transaction id. Some earlier
|
|
||||||
* code wrote {@code System.currentTimeMillis()} into txn_id, leaving rows that belong to no
|
|
||||||
* transaction at all. This database holds ₹7.29 lakh of them, and because a cashier's position
|
|
||||||
* is cumulative they were being carried forward for ever: the Closing Cash Report opened Rohit
|
|
||||||
* Dubey's drawer at ₹9,99,106 when the teller's own GL held ₹8,070.
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared
|
|
||||||
* with the day register, the reconciliation screen and the withdrawal stock check, so a drawer
|
|
||||||
* reads the same on all four.
|
|
||||||
*/
|
|
||||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
|
||||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) "
|
|
||||||
+ "AND " + CountedCashLedger.REAL_TXN_ID
|
|
||||||
// A breakdown has to add up to the transaction it belongs to. Where it does not, the
|
|
||||||
// notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes
|
|
||||||
// recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven
|
|
||||||
// such transactions in this database were carrying ₹1.68 lakh that never existed.
|
|
||||||
//
|
|
||||||
// The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation,
|
|
||||||
// so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the
|
|
||||||
// nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed
|
|
||||||
// breakdown, and treating it as one silently dropped an entire genuine withdrawal (and
|
|
||||||
// everything counted after it) out of the cashier's position.
|
|
||||||
//
|
|
||||||
// Compared per transaction inside a correlated subquery rather than as a join, so a
|
|
||||||
// breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a
|
|
||||||
// manual GL entry keyed by its decoded reference -- is untouched by this test and left to
|
|
||||||
// CountedCashLedger's rule above.
|
|
||||||
+ "AND NOT EXISTS ("
|
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction mt "
|
|
||||||
+ " WHERE mt.id = std.txn_id"
|
|
||||||
+ " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)"
|
|
||||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) ";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
|
||||||
*
|
|
||||||
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
|
|
||||||
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
|
|
||||||
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
|
|
||||||
* without opening three separate screens.
|
|
||||||
*
|
|
||||||
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
|
|
||||||
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
|
|
||||||
* has no such column — falls back to the date it was submitted on.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("day-book")
|
|
||||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
|
||||||
@QueryParam("status") final String status) {
|
|
||||||
|
|
||||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
|
|
||||||
// Each register is left-joined to the verification table and to an attachment count, so a
|
|
||||||
// row already carries its physical-check verdict and whether a slip was scanned in — the
|
|
||||||
// screen never has to fire a follow-up call per voucher.
|
|
||||||
final String verifyJoin = """
|
|
||||||
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
|
|
||||||
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
|
||||||
va ON va.voucher_no = r.voucher_no
|
|
||||||
""";
|
|
||||||
final String verifyCols = """
|
|
||||||
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
|
|
||||||
vv.verified_by_name, vv.verified_at,
|
|
||||||
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
|
|
||||||
COALESCE(va.n, 0) AS attachment_count
|
|
||||||
""";
|
|
||||||
|
|
||||||
// Cashier deposits — money in, so the credit side.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no,
|
|
||||||
-- Same rule as the withdrawal register below: an approved deposit is cash in
|
|
||||||
-- the drawer, a pending one is a request nobody has taken money for yet.
|
|
||||||
CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
|
|
||||||
'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
|
|
||||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
|
||||||
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_cashier_deposit_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Clerk withdrawals — money out, so the debit side.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no,
|
|
||||||
-- Filed by what happened to the money, not by who typed the request. Once the
|
|
||||||
-- notes are handed over it is the cashier's drawer that is short, so the row
|
|
||||||
-- belongs in their section; while it is still pending or was rejected no cash
|
|
||||||
-- has moved and it is the clerk's outstanding request.
|
|
||||||
CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
|
|
||||||
'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
|
|
||||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
|
||||||
COALESCE(paid.officer_username, r.clerk_name) AS handled_by,
|
|
||||||
r.manager_name AS approved_by, r.tok_no AS txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_withdrawal_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
-- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it
|
|
||||||
-- is filed under whoever's cash actually moved. Sectioning it by the maker put
|
|
||||||
-- every counter payment in the clerk's column and left the cashier's own section
|
|
||||||
-- short by exactly what they had paid out -- the one figure their drawer is
|
|
||||||
-- checked against. The payer is read from the denomination breakdown, which is
|
|
||||||
-- written when the cash is handed over, and the By column names them too.
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT d.officer_username FROM m_savings_txn_denomination d
|
|
||||||
WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT)
|
|
||||||
AND d.officer_username IS NOT NULL
|
|
||||||
LIMIT 1) paid ON TRUE
|
|
||||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Account closures — a full-balance payout, so the debit side. Its own register rather than
|
|
||||||
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
|
|
||||||
// close() computes at approval) and the cash handed over is the ROUNDED
|
|
||||||
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
|
|
||||||
// paisa-exact closing_amount posted to the member's ledger.
|
|
||||||
//
|
|
||||||
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
|
|
||||||
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
|
|
||||||
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
|
|
||||||
// put rows with no cash behind them (a rejected closure has no amount at all) into the
|
|
||||||
// teller's day book. And the day they belong to is the day the cash left, so this filters on
|
|
||||||
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
|
||||||
// step to reach.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no,
|
|
||||||
-- A closure is paid out at the counter, so it is the cashier's work.
|
|
||||||
-- This column was missing entirely and the row mapper reads it unconditionally,
|
|
||||||
-- so every CLOSURE row threw instead of appearing.
|
|
||||||
'CASHIER' AS section,
|
|
||||||
'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
|
||||||
'SAVINGS' AS account_type, r.client_name AS party,
|
|
||||||
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
|
||||||
r.payment_type_name, r.description, r.status,
|
|
||||||
COALESCE(r.disbursed_by, r.clerk_name) AS handled_by,
|
|
||||||
r.manager_name AS approved_by, r.tok_no AS txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_closure_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE r.status = 'CLOSED'
|
|
||||||
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
|
|
||||||
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no, 'CLERK' AS section,
|
|
||||||
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
|
|
||||||
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
|
|
||||||
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
|
|
||||||
r.description, r.status, r.clerk_name AS handled_by,
|
|
||||||
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_disbursement_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Cash taken at the counter for a Demand Draft — a receipt, so the credit side.
|
|
||||||
//
|
|
||||||
// The Sponsor DD register is not one of the three above and has no voucher_no of its own, so
|
|
||||||
// the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a
|
|
||||||
// day book with an empty CR column and no sign of the money. The voucher is joined the other
|
|
||||||
// way round, from m_voucher.reference_id, which is where the link actually lives.
|
|
||||||
//
|
|
||||||
// Only the cash-funded drafts appear, and only those the cashier took the notes for
|
|
||||||
// directly: an account-debit or cheque draft never crosses the counter, and one funded from
|
|
||||||
// a cashier deposit request is already listed above under that register's own row.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind,
|
|
||||||
NULL AS account_no, 'GL' AS account_type,
|
|
||||||
COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party,
|
|
||||||
r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount,
|
|
||||||
'CASH' AS payment_type_name,
|
|
||||||
CONCAT('DD for ', r.beneficiary_name) AS description,
|
|
||||||
r.status, mk.username AS handled_by, ck.username AS approved_by,
|
|
||||||
CAST(r.id AS VARCHAR) AS txn_id,
|
|
||||||
r.created_date AS submitted_at, r.checker_date AS actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_sponsor_dd_issue_request r
|
|
||||||
LEFT JOIN m_appuser mk ON mk.id = r.maker_id
|
|
||||||
LEFT JOIN m_appuser ck ON ck.id = r.checker_id
|
|
||||||
LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST'
|
|
||||||
AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%'
|
|
||||||
""" + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """
|
|
||||||
WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL
|
|
||||||
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Everything else that moved money on this date and is not one of the registers above:
|
|
||||||
// interest posted, a charge collected from a balance, a counter deposit or withdrawal, an
|
|
||||||
// escheat, a manual journal.
|
|
||||||
//
|
|
||||||
// Read from the voucher register rather than from another set of source tables, because
|
|
||||||
// that is the one place every posting lands whatever raised it — including the nightly jobs,
|
|
||||||
// which have no request row anywhere to read them from. Without this the officer's sheet
|
|
||||||
// showed only what somebody had filed a request for, and a day of system postings against
|
|
||||||
// members' accounts was invisible on the very page meant to account for the day.
|
|
||||||
//
|
|
||||||
// The registers above are excluded by reference type, so a voucher already listed as a
|
|
||||||
// deposit or a withdrawal request cannot appear twice under a different heading.
|
|
||||||
//
|
|
||||||
// Status is reported as COMPLETED: a voucher only exists once its posting has committed, so
|
|
||||||
// there is no pending state to represent — unlike a request, which can sit unactioned.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT v.voucher_no,
|
|
||||||
-- A nightly batch belongs to the day it worked on, not the minute it ran.
|
|
||||||
--
|
|
||||||
-- A voucher's created_at is a wall-clock instant, while the transaction it
|
|
||||||
-- posts and the journal entry behind it both carry the business date. So the
|
|
||||||
-- interest job that fires at 00:10 stamps its vouchers with tomorrow while
|
|
||||||
-- the ledger says yesterday — and the day it actually belongs to had already
|
|
||||||
-- been counted, reported and signed off hours earlier.
|
|
||||||
--
|
|
||||||
-- Rather than re-open a closed day, such a voucher is listed on the day it
|
|
||||||
-- surfaces, under its own heading, saying which day's work it is. The date is
|
|
||||||
-- taken from the transaction it posts; where there is none — a manual journal
|
|
||||||
-- keyed straight to the ledger — an early-hours timestamp is treated the same
|
|
||||||
-- way, because nobody is at a counter at 3am.
|
|
||||||
CASE WHEN COALESCE(t.transaction_date, CAST(v.created_at AS DATE))
|
|
||||||
< CAST(v.created_at AS DATE)
|
|
||||||
OR (v.savings_txn_id IS NULL
|
|
||||||
AND EXTRACT(HOUR FROM v.created_at) < 6)
|
|
||||||
THEN 'SYSTEM_PRIOR' ELSE 'SYSTEM' END AS section,
|
|
||||||
-- Which day's work this is, for the heading on the prior-day block.
|
|
||||||
COALESCE(t.transaction_date, CAST(v.created_at AS DATE) - 1) AS effective_date,
|
|
||||||
CASE WHEN v.voucher_no LIKE 'CR/%' THEN 'CR' ELSE 'DR' END AS side,
|
|
||||||
v.voucher_type AS kind,
|
|
||||||
-- The member's own account number, never a voucher line's — a line carries a
|
|
||||||
-- GL code there, and showing 500201 in an Account column reads as an account
|
|
||||||
-- that does not exist. A journal with no member behind it shows none.
|
|
||||||
sa.account_no,
|
|
||||||
NULL AS account_type,
|
|
||||||
COALESCE(c.display_name, g.display_name, cr.party_name, dr.party_name) AS party,
|
|
||||||
v.amount,
|
|
||||||
NULL AS payment_type_name,
|
|
||||||
-- The two sides in one phrase, which is what a journal voucher is read as.
|
|
||||||
CONCAT(COALESCE(dr.party_name, '?'), ' → ', COALESCE(cr.party_name, '?')) AS description,
|
|
||||||
'COMPLETED' AS status,
|
|
||||||
v.created_by_username AS handled_by,
|
|
||||||
-- Who checked it, if anybody has. Not aliased from verifyCols below, which
|
|
||||||
-- selects verified_by_name under its own name for the verification panel --
|
|
||||||
-- the row mapper reads both columns, so each has to be present exactly once.
|
|
||||||
vv.verified_by_name AS approved_by,
|
|
||||||
CAST(v.savings_txn_id AS VARCHAR) AS txn_id,
|
|
||||||
v.created_at AS submitted_at, v.created_at AS actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_voucher v
|
|
||||||
-- Either link: savings_txn_id is stamped by a step after the voucher is written
|
|
||||||
-- and is empty on every interest posting, where reference_id already carries the
|
|
||||||
-- same transaction id. Without the fallback the prior-day test never fires on the
|
|
||||||
-- nightly postings it was written for.
|
|
||||||
LEFT JOIN m_savings_account_transaction t
|
|
||||||
ON t.id = COALESCE(v.savings_txn_id,
|
|
||||||
CASE WHEN v.reference_type = 'SAVINGS_TRANSACTION'
|
|
||||||
THEN v.reference_id END)
|
|
||||||
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT party_name, account_no FROM m_voucher_line
|
|
||||||
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
|
|
||||||
ORDER BY line_no LIMIT 1) dr ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT party_name, account_no FROM m_voucher_line
|
|
||||||
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
|
||||||
ORDER BY line_no LIMIT 1) cr ON TRUE
|
|
||||||
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = v.voucher_no
|
|
||||||
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
|
||||||
va ON va.voucher_no = v.voucher_no
|
|
||||||
WHERE CAST(v.created_at AS DATE) = CAST(? AS DATE)
|
|
||||||
AND (v.reference_type IS NULL OR v.reference_type NOT IN (
|
|
||||||
'CASHIER_DEPOSIT_REQUEST', 'CLERK_WITHDRAWAL_REQUEST',
|
|
||||||
'CLERK_CLOSURE_REQUEST', 'CLERK_DISBURSEMENT_REQUEST',
|
|
||||||
'SPONSOR_DD_ISSUE_REQUEST'))
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
|
||||||
// ordered as one result set, and the row count for a single day is small.
|
|
||||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
|
||||||
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
|
|
||||||
|
|
||||||
final List<Map<String, Object>> filtered = new ArrayList<>();
|
|
||||||
BigDecimal creditTotal = BigDecimal.ZERO;
|
|
||||||
BigDecimal debitTotal = BigDecimal.ZERO;
|
|
||||||
// Kept apart from the day's own totals so a reader can see the overnight batch without it
|
|
||||||
// silently joining figures that were already reported and signed.
|
|
||||||
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
|
||||||
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
for (final Map<String, Object> row : rows) {
|
|
||||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
filtered.add(row);
|
|
||||||
|
|
||||||
// Only approved money is real money: a pending or rejected voucher must not inflate the
|
|
||||||
// day's totals, even though it still belongs in the listing. The source registers don't
|
|
||||||
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
|
|
||||||
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
|
|
||||||
// any of them meant a completed transaction moved real money out yet never showed up in
|
|
||||||
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
|
||||||
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
|
||||||
final String rowStatus = (String) row.get("status");
|
|
||||||
// A batch that posted against yesterday is listed today but is not today's money: it
|
|
||||||
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
|
||||||
// it to this day's totals would make them disagree with the ledger for either date.
|
|
||||||
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
|
||||||
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
|
||||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
|
||||||
if (amount != null) {
|
|
||||||
if ("CR".equals(row.get("side"))) {
|
|
||||||
creditTotal = creditTotal.add(amount);
|
|
||||||
} else {
|
|
||||||
debitTotal = debitTotal.add(amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (priorDay) {
|
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
|
||||||
if (amount != null) {
|
|
||||||
if ("CR".equals(row.get("side"))) {
|
|
||||||
priorCreditTotal = priorCreditTotal.add(amount);
|
|
||||||
} else {
|
|
||||||
priorDebitTotal = priorDebitTotal.add(amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.sort((a, b) -> {
|
|
||||||
final Object left = b.get("submittedAt");
|
|
||||||
final Object right = a.get("submittedAt");
|
|
||||||
if (left == null && right == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (left == null) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (right == null) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return left.toString().compareTo(right.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("date", txnDate);
|
|
||||||
result.put("rows", filtered);
|
|
||||||
result.put("count", filtered.size());
|
|
||||||
// Counter figures: the credit and debit the customer side of the day adds up to.
|
|
||||||
result.put("creditTotal", creditTotal);
|
|
||||||
result.put("debitTotal", debitTotal);
|
|
||||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
|
||||||
// The overnight batch, reported separately: this is yesterday's work surfacing today.
|
|
||||||
result.put("priorCreditTotal", priorCreditTotal);
|
|
||||||
result.put("priorDebitTotal", priorDebitTotal);
|
|
||||||
return gson.toJson(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A date column that only some of the union'd queries select.
|
|
||||||
*
|
|
||||||
* The six day-book queries do not share a column list, and adding a column to all of them so one
|
|
||||||
* can carry it would mean five queries stating a date they were handed as a parameter. Absent is
|
|
||||||
* reported as null rather than as an error.
|
|
||||||
*/
|
|
||||||
private String optionalDate(final java.sql.ResultSet rs, final String column) {
|
|
||||||
try {
|
|
||||||
final java.sql.Date value = rs.getDate(column);
|
|
||||||
return value == null ? null : value.toLocalDate().toString();
|
|
||||||
} catch (final java.sql.SQLException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
|
||||||
// Which part of the day's work this row belongs to. The registers do not divide along
|
|
||||||
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
|
|
||||||
// where the entry came from, not who typed it.
|
|
||||||
row.put("section", rs.getString("section"));
|
|
||||||
|
|
||||||
// Which day's work the row is: normally the day being reported, but a nightly batch posts
|
|
||||||
// against the previous business date while surfacing on this one. Only the SYSTEM query
|
|
||||||
// selects it, so it is read defensively rather than added to five other queries that would
|
|
||||||
// all report the same date they were asked for.
|
|
||||||
row.put("effectiveDate", optionalDate(rs, "effective_date"));
|
|
||||||
row.put("side", rs.getString("side"));
|
|
||||||
row.put("kind", rs.getString("kind"));
|
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
|
||||||
row.put("accountType", rs.getString("account_type"));
|
|
||||||
row.put("party", rs.getString("party"));
|
|
||||||
row.put("amount", rs.getBigDecimal("amount"));
|
|
||||||
row.put("paymentTypeName", rs.getString("payment_type_name"));
|
|
||||||
row.put("description", rs.getString("description"));
|
|
||||||
row.put("status", rs.getString("status"));
|
|
||||||
row.put("handledBy", rs.getString("handled_by"));
|
|
||||||
row.put("approvedBy", rs.getString("approved_by"));
|
|
||||||
row.put("txnId", rs.getString("txn_id"));
|
|
||||||
|
|
||||||
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
|
|
||||||
row.put("verifyStatus", rs.getString("verify_status"));
|
|
||||||
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
|
|
||||||
row.put("verifyRemarks", rs.getString("verify_remarks"));
|
|
||||||
row.put("verifiedByName", rs.getString("verified_by_name"));
|
|
||||||
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
|
|
||||||
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
|
|
||||||
|
|
||||||
// How the exception ended, if it has. Without this the day book keeps showing a closed
|
|
||||||
// exception as though it were still open — the flag never clears on screen.
|
|
||||||
row.put("resolutionStatus", rs.getString("resolution_status"));
|
|
||||||
row.put("resolutionNotes", rs.getString("resolution_notes"));
|
|
||||||
row.put("resolvedByName", rs.getString("resolved_by_name"));
|
|
||||||
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
|
|
||||||
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
|
|
||||||
row.put("attachmentCount", rs.getInt("attachment_count"));
|
|
||||||
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
|
|
||||||
// form the browser cannot parse reliably.
|
|
||||||
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
|
|
||||||
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
|
|
||||||
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
|
|
||||||
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@GET
|
@GET
|
||||||
@Path("closing")
|
@Path("closing")
|
||||||
public String closing(@QueryParam("date") String date) {
|
public String closing(@QueryParam("date") String date) {
|
||||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||||
|
|
||||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
||||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
|
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
||||||
|
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
|
||||||
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
|
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
|
||||||
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
|
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
||||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
+ "FROM m_savings_txn_denomination std "
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||||
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
||||||
// Every note and coin that reached the drawer or left it, per denomination.
|
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
||||||
//
|
|
||||||
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
|
|
||||||
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
|
|
||||||
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
|
|
||||||
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
|
|
||||||
// counted against. So the vault allocation and the settlement back are included on the same
|
|
||||||
// footing as the counter work, and each row says where its notes came from.
|
|
||||||
//
|
|
||||||
// Cashier scoping differs per leg because each table records the party differently: the
|
|
||||||
// counter tables name the acting user, the cash tables name the cashier row.
|
|
||||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
|
||||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
|
||||||
|
|
||||||
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
|
|
||||||
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
|
|
||||||
+ "FROM ("
|
|
||||||
// Counter work: what customers handed over and were paid.
|
|
||||||
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
|
|
||||||
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
|
|
||||||
+ " FROM m_savings_txn_denomination std "
|
|
||||||
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
|
||||||
+ " UNION ALL "
|
|
||||||
// Drawn from the vault in the morning.
|
|
||||||
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
|
|
||||||
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
|
||||||
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
|
|
||||||
+ vaultCashierFilter
|
|
||||||
+ " UNION ALL "
|
|
||||||
// Handed back to the vault at close.
|
|
||||||
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
|
|
||||||
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
|
|
||||||
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
|
|
||||||
+ settleCashierFilter
|
|
||||||
+ ") legs "
|
|
||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
|
|
||||||
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
|
|
||||||
+ "ORDER BY legs.denomination_value DESC, dm.kind";
|
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
|
||||||
args.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
args.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
args.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
args.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
args.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
args.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
final long vaultInCount = rs.getLong("vault_in_count");
|
long depositCount = rs.getLong("deposit_count");
|
||||||
final long depositCount = rs.getLong("deposit_count");
|
long withdrawalCount = rs.getLong("withdrawal_count");
|
||||||
final long withdrawalCount = rs.getLong("withdrawal_count");
|
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
||||||
final long settledOutCount = rs.getLong("settled_out_count");
|
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
||||||
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
|
|
||||||
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
|
|
||||||
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
|
|
||||||
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
|
|
||||||
|
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("denominationId", rs.getLong("denomination_id"));
|
row.put("denominationId", rs.getLong("denomination_id"));
|
||||||
row.put("denominationValue", rs.getInt("denomination_value"));
|
row.put("denominationValue", rs.getInt("denomination_value"));
|
||||||
row.put("kind", rs.getString("kind"));
|
row.put("kind", rs.getString("kind"));
|
||||||
row.put("vaultInCount", vaultInCount);
|
|
||||||
row.put("vaultInAmount", vaultInAmount);
|
|
||||||
row.put("depositCount", depositCount);
|
row.put("depositCount", depositCount);
|
||||||
row.put("depositAmount", depositAmount);
|
row.put("depositAmount", depositAmount);
|
||||||
row.put("withdrawalCount", withdrawalCount);
|
row.put("withdrawalCount", withdrawalCount);
|
||||||
row.put("withdrawalAmount", withdrawalAmount);
|
row.put("withdrawalAmount", withdrawalAmount);
|
||||||
row.put("settledOutCount", settledOutCount);
|
row.put("netCount", depositCount - withdrawalCount);
|
||||||
row.put("settledOutAmount", settledOutAmount);
|
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
||||||
// What is left of this denomination: everything that came in, less everything that went
|
|
||||||
// out. This is the figure a teller counts their notes against, per note.
|
|
||||||
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
|
|
||||||
row.put("netAmount",
|
|
||||||
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
|
|
||||||
return row;
|
return row;
|
||||||
}, args.toArray());
|
}, txnDate);
|
||||||
|
|
||||||
BigDecimal depositTotal = BigDecimal.ZERO;
|
BigDecimal depositTotal = BigDecimal.ZERO;
|
||||||
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
||||||
@@ -603,7 +75,6 @@ public class CashReportApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
|
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
|
||||||
final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : "";
|
|
||||||
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
|
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
|
||||||
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
|
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
|
||||||
@@ -613,16 +84,10 @@ public class CashReportApiResource {
|
|||||||
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
|
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
|
||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
|
||||||
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
|
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
|
||||||
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
|
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
|
||||||
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
|
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
|
||||||
+ "ORDER BY ced.denomination_value DESC, dm.kind";
|
+ "ORDER BY ced.denomination_value DESC, dm.kind";
|
||||||
|
|
||||||
final List<Object> exArgs = new ArrayList<>();
|
|
||||||
exArgs.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
exArgs.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
|
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
|
||||||
long receivedCount = rs.getLong("received_count");
|
long receivedCount = rs.getLong("received_count");
|
||||||
long givenCount = rs.getLong("given_count");
|
long givenCount = rs.getLong("given_count");
|
||||||
@@ -639,135 +104,18 @@ public class CashReportApiResource {
|
|||||||
row.put("netCount", receivedCount - givenCount);
|
row.put("netCount", receivedCount - givenCount);
|
||||||
row.put("netAmount", receivedAmount.subtract(givenAmount));
|
row.put("netAmount", receivedAmount.subtract(givenAmount));
|
||||||
return row;
|
return row;
|
||||||
}, exArgs.toArray());
|
}, txnDate);
|
||||||
|
|
||||||
// Cash taken over the counter for a Demand Draft.
|
|
||||||
//
|
|
||||||
// A cash DD asks for a denomination breakdown but does not insist on one, so most are
|
|
||||||
// recorded with none at all -- and the whole of this report is built from breakdowns. The
|
|
||||||
// money is real: the ledger holds it (Cash at Vault is debited when the notes are taken)
|
|
||||||
// and the cashier is holding the notes, yet the drawer figure omitted every rupee of it.
|
|
||||||
//
|
|
||||||
// Counted from the request itself rather than from a breakdown, so it appears whether or not
|
|
||||||
// the notes were itemised. Excluded are DDs funded through a cashier deposit request: that
|
|
||||||
// register counted the same notes already, and adding them here would count them twice.
|
|
||||||
final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN ("
|
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
|
||||||
final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) "
|
|
||||||
+ "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count "
|
|
||||||
+ "FROM m_sponsor_dd_issue_request dd "
|
|
||||||
+ "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL "
|
|
||||||
+ "AND dd.status <> 'REJECTED' "
|
|
||||||
+ "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter;
|
|
||||||
|
|
||||||
final List<Object> ddArgs = new ArrayList<>();
|
|
||||||
ddArgs.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
ddArgs.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal ddCashTotal = BigDecimal.ZERO;
|
|
||||||
long ddCashCount = 0;
|
|
||||||
try {
|
|
||||||
final Map<String, Object> ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray());
|
|
||||||
ddCashTotal = (BigDecimal) ddRow.get("cash_total");
|
|
||||||
ddCashCount = ((Number) ddRow.get("cash_count")).longValue();
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
// A tenant without the Sponsor DD tables still gets its closing report.
|
|
||||||
log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("date", txnDate);
|
result.put("date", txnDate);
|
||||||
result.put("rows", rows);
|
result.put("rows", rows);
|
||||||
// The denomination-wise total, which is what the rows below add up to.
|
|
||||||
result.put("depositTotal", depositTotal);
|
result.put("depositTotal", depositTotal);
|
||||||
result.put("withdrawalTotal", withdrawalTotal);
|
result.put("withdrawalTotal", withdrawalTotal);
|
||||||
// Reported separately as well, so a screen can show where the difference comes from rather
|
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
|
||||||
// than presenting a deposit total its own rows do not explain.
|
|
||||||
result.put("ddCashTotal", ddCashTotal);
|
|
||||||
result.put("ddCashCount", ddCashCount);
|
|
||||||
result.put("depositTotalWithDd", depositTotal.add(ddCashTotal));
|
|
||||||
result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal));
|
|
||||||
result.put("exchangeRows", exchangeRows);
|
result.put("exchangeRows", exchangeRows);
|
||||||
result.put("cashierId", scopedCashierId);
|
|
||||||
result.put("scopedToCashier", scopedCashierId != null);
|
|
||||||
|
|
||||||
// Opening and closing cash in the drawer.
|
|
||||||
//
|
|
||||||
// netTotal above is only the day's counter movement, which is not what the cashier is
|
|
||||||
// holding. A closing figure has to carry the previous balance forward, so it is derived from
|
|
||||||
// the cumulative position -- vault allocations, settlements, inter-cashier legs and
|
|
||||||
// exchanges as well as the counter work -- read once as of the day before and once as of the
|
|
||||||
// day itself. The gap between the two is the day's entire cash movement, a wider figure than
|
|
||||||
// deposits minus withdrawals, which is why both are reported rather than one derived from
|
|
||||||
// the other: when they differ, the difference is vault and inter-cashier traffic.
|
|
||||||
//
|
|
||||||
// Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the
|
|
||||||
// branch day register, not to this report.
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId,
|
|
||||||
LocalDate.parse(txnDate).minusDays(1).toString());
|
|
||||||
final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate);
|
|
||||||
result.put("openingBalance", openingBalance);
|
|
||||||
result.put("closingBalance", closingBalance);
|
|
||||||
result.put("cashMovement", closingBalance.subtract(openingBalance));
|
|
||||||
|
|
||||||
// The day on its own terms, which is what a cashier actually reconciles.
|
|
||||||
//
|
|
||||||
// A cashier does not carry a balance: they draw cash from the vault in the morning, work
|
|
||||||
// the counter, and hand it back at close. So the cumulative figures above answer a
|
|
||||||
// question nobody at the counter asks — and one carrying years of history answers it
|
|
||||||
// with a number that cannot be counted against anything on the desk. These four are the
|
|
||||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
|
||||||
// back. What is left is what should physically be in the drawer.
|
|
||||||
result.put("todayVaultIn", dayLegTotal(
|
|
||||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
|
||||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
|
||||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
|
||||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
|
||||||
scopedCashierId, txnDate));
|
|
||||||
result.put("todaySettledOut", dayLegTotal(
|
|
||||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
|
||||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
|
||||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
|
||||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
|
||||||
scopedCashierId, txnDate));
|
|
||||||
result.put("todayInterCashierIn", dayLegTotal(
|
|
||||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
|
||||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
|
||||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
|
||||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
|
||||||
scopedCashierId, txnDate));
|
|
||||||
result.put("todayInterCashierOut", dayLegTotal(
|
|
||||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
|
||||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
|
||||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
|
||||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
|
||||||
scopedCashierId, txnDate));
|
|
||||||
}
|
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One day's total for a single cash leg, in rupees. Zero rather than null when there is none. */
|
|
||||||
private BigDecimal dayLegTotal(final String sql, final Long cashierId, final String txnDate) {
|
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class, cashierId, txnDate);
|
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What one cashier is holding at the end of a given date, in rupees.
|
|
||||||
*
|
|
||||||
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
|
|
||||||
* report and the position screen can never disagree about the same drawer.
|
|
||||||
*/
|
|
||||||
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
|
|
||||||
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
|
|
||||||
final Object total = position.get("totalAmount");
|
|
||||||
return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("excess")
|
@Path("excess")
|
||||||
public String excess(@QueryParam("threshold") BigDecimal threshold,
|
public String excess(@QueryParam("threshold") BigDecimal threshold,
|
||||||
@@ -1012,30 +360,6 @@ public class CashReportApiResource {
|
|||||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
|
|
||||||
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
|
|
||||||
* bank have", as opposed to cashier-position which answers the same question for a cashier.
|
|
||||||
* See {@link VaultPositionService} for how the anchor is chosen.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("vault-position")
|
|
||||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
|
||||||
@QueryParam("asOfDate") final String asOfDate) {
|
|
||||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
|
||||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
|
||||||
|
|
||||||
if (tellerId == null) {
|
|
||||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
|
||||||
final List<Map<String, Object>> all = new ArrayList<>();
|
|
||||||
for (final Long id : tellerIds) {
|
|
||||||
all.add(vaultPositionService.buildPositionForVault(id, dateFilter));
|
|
||||||
}
|
|
||||||
return gson.toJson(all);
|
|
||||||
}
|
|
||||||
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
||||||
* Returns denomination-wise cash received by the cashier from vault on the given date
|
* Returns denomination-wise cash received by the cashier from vault on the given date
|
||||||
@@ -1090,17 +414,7 @@ public class CashReportApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
|
||||||
* Denomination-wise net cash a cashier is holding as of a date: everything allocated to them,
|
|
||||||
* settled or handed on by them, and taken or paid over their counter, netted per note and coin.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Public because the settlement register checks against it before accepting a hand-over. It has
|
|
||||||
* to be this figure and not a second sum of the same eight legs: two implementations of "what
|
|
||||||
* does this drawer hold" drift, and the one place that matters is the moment somebody is told
|
|
||||||
* they cannot settle what they do not have.
|
|
||||||
*/
|
|
||||||
public Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
|
|
||||||
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
||||||
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
|
||||||
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
|
||||||
@@ -1124,7 +438,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Settlements back to vault
|
// Settlements back to vault
|
||||||
@@ -1150,7 +464,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||||
@@ -1283,44 +597,9 @@ public class CashReportApiResource {
|
|||||||
baseParams = new Object[] { txnDate };
|
baseParams = new Object[] { txnDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve
|
// Base grouped query (reused for count, totals, and paged data)
|
||||||
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
|
|
||||||
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings
|
|
||||||
// transaction id (e.g. "4"), never what was actually printed on the voucher.
|
|
||||||
//
|
|
||||||
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
|
|
||||||
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
|
|
||||||
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
|
|
||||||
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
|
|
||||||
// string-equality join against cdr.txn_id can never match it. The second half of each OR
|
|
||||||
// below re-derives that same decimal value from the hex string to match it -- guarded to
|
|
||||||
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
|
|
||||||
// contains a-f, is never sent through the hex cast at all).
|
|
||||||
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
||||||
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
||||||
// A GL-type row (no client_id) carries the GL account's own code in account_no
|
|
||||||
// rather than a savings/current account number, so its "name" has to come from the
|
|
||||||
// chart of accounts, not from a client that was never party to the transaction.
|
|
||||||
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
|
|
||||||
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
|
|
||||||
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
|
|
||||||
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
|
|
||||||
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
|
|
||||||
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
|
|
||||||
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
|
|
||||||
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
|
|
||||||
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
|
||||||
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
|
|
||||||
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
|
|
||||||
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
|
||||||
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
|
|
||||||
// (the column records which way cash moved), but its request lives in its own
|
|
||||||
// register -- without this join the closure's real voucher number is never found and
|
|
||||||
// the report falls back to showing the bare savings-transaction id instead.
|
|
||||||
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
|
|
||||||
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||||
+ officerFilter
|
+ officerFilter
|
||||||
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
||||||
@@ -1339,42 +618,20 @@ public class CashReportApiResource {
|
|||||||
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
final java.math.BigDecimal totalDr = (java.math.BigDecimal) totalsRow.get("total_dr");
|
||||||
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
final java.math.BigDecimal totalCr = (java.math.BigDecimal) totalsRow.get("total_cr");
|
||||||
|
|
||||||
// 2. Paged data query. Each row here is one physical-cash leg -- the only one a manual GL
|
// 2. Paged data query
|
||||||
// entry ever has a denomination breakdown for -- so on its own it names only the cash side
|
final String dataSql = "SELECT "
|
||||||
// (e.g. "CASH AT VAULT") and neither the voucher's other leg nor which two accounts the
|
+ " std.txn_id AS voucher_no, "
|
||||||
// entry actually moved money between. Every voucher raised through VoucherService (both this
|
|
||||||
// one and an ordinary counter deposit) has its full pair of legs recorded in m_voucher_line,
|
|
||||||
// the same table the My Work register reads its own debit/credit columns from -- joined here
|
|
||||||
// by voucher_no so this report shows the identical pair rather than only the half this
|
|
||||||
// table's own denomination row happens to know about.
|
|
||||||
final String baseSql = "SELECT "
|
|
||||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
|
||||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
|
||||||
+ " std.account_no AS ac_no, "
|
+ " std.account_no AS ac_no, "
|
||||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
|
||||||
+ " MAX(std.txn_date) AS txn_date, "
|
+ " MAX(std.txn_date) AS txn_date, "
|
||||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||||
+ " MAX(std.created_at) AS sort_key, "
|
|
||||||
+ " MAX(std.officer_username) AS teller_name, "
|
+ " MAX(std.officer_username) AS teller_name, "
|
||||||
+ " MAX(std.payment_type) AS payment_type, "
|
+ " MAX(std.payment_type) AS payment_type, "
|
||||||
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
+ " CASE WHEN MAX(std.client_id) IS NULL THEN 'GL' ELSE 'SAVINGS' END AS txn_source, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
+ " SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS dr_amount, "
|
||||||
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
+ " SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS cr_amount "
|
||||||
+ baseGroupSql;
|
+ baseGroupSql
|
||||||
|
+ "ORDER BY MAX(std.created_at) "
|
||||||
final String dataSql = "SELECT b.*, "
|
|
||||||
+ " dr.party_name AS debit_party, dr.account_no AS debit_account, "
|
|
||||||
+ " cr.party_name AS credit_party, cr.account_no AS credit_account "
|
|
||||||
+ "FROM (" + baseSql + ") b "
|
|
||||||
+ "LEFT JOIN LATERAL ("
|
|
||||||
+ " SELECT party_name, account_no FROM m_voucher_line"
|
|
||||||
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'DR'"
|
|
||||||
+ " ORDER BY line_no LIMIT 1) dr ON TRUE "
|
|
||||||
+ "LEFT JOIN LATERAL ("
|
|
||||||
+ " SELECT party_name, account_no FROM m_voucher_line"
|
|
||||||
+ " WHERE voucher_no = b.voucher_no AND entry_type = 'CR'"
|
|
||||||
+ " ORDER BY line_no LIMIT 1) cr ON TRUE "
|
|
||||||
+ "ORDER BY b.sort_key "
|
|
||||||
+ "LIMIT ? OFFSET ?";
|
+ "LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
// Append limit and offset to params
|
// Append limit and offset to params
|
||||||
@@ -1385,7 +642,7 @@ public class CashReportApiResource {
|
|||||||
|
|
||||||
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
row.put("voucherNo", rs.getLong("voucher_no"));
|
||||||
row.put("acNo", rs.getString("ac_no"));
|
row.put("acNo", rs.getString("ac_no"));
|
||||||
row.put("acName", rs.getString("ac_name"));
|
row.put("acName", rs.getString("ac_name"));
|
||||||
row.put("txnDate", rs.getString("txn_date"));
|
row.put("txnDate", rs.getString("txn_date"));
|
||||||
@@ -1395,12 +652,6 @@ public class CashReportApiResource {
|
|||||||
row.put("txnSource", rs.getString("txn_source"));
|
row.put("txnSource", rs.getString("txn_source"));
|
||||||
row.put("dr", rs.getBigDecimal("dr_amount"));
|
row.put("dr", rs.getBigDecimal("dr_amount"));
|
||||||
row.put("cr", rs.getBigDecimal("cr_amount"));
|
row.put("cr", rs.getBigDecimal("cr_amount"));
|
||||||
// Falls back to null rather than the cash-leg-only ac_no/ac_name above when a voucher
|
|
||||||
// was raised before m_voucher_line existed, or belongs to no register that writes one.
|
|
||||||
row.put("debitParty", rs.getString("debit_party"));
|
|
||||||
row.put("debitAccount", rs.getString("debit_account"));
|
|
||||||
row.put("creditParty", rs.getString("credit_party"));
|
|
||||||
row.put("creditAccount", rs.getString("credit_account"));
|
|
||||||
return row;
|
return row;
|
||||||
}, pagedParams);
|
}, pagedParams);
|
||||||
|
|
||||||
|
|||||||
-181
@@ -1,181 +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.cashreport.service;
|
|
||||||
|
|
||||||
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.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Denomination-wise cash a vault (teller) is currently holding.
|
|
||||||
*
|
|
||||||
* Anchored on the most recent business day (on or before the target date) whose branch day
|
|
||||||
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
|
|
||||||
* teller), then walked forward by everything settled into this vault and handed out of it since.
|
|
||||||
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
|
|
||||||
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
|
|
||||||
* never includes cash that still physically sits with a cashier — mixing the two double-counts
|
|
||||||
* whatever a cashier settles back later, once already folded into the branch total, again as the
|
|
||||||
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
|
|
||||||
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
|
|
||||||
* still returns something rather than zero.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class VaultPositionService {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
|
|
||||||
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
|
|
||||||
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
|
|
||||||
|
|
||||||
public Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
|
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("tellerId", tellerId);
|
|
||||||
result.put("asOfDate", dateFilter);
|
|
||||||
|
|
||||||
String tellerName = null;
|
|
||||||
Long officeId = null;
|
|
||||||
try {
|
|
||||||
final Map<String, Object> teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?",
|
|
||||||
tellerId);
|
|
||||||
tellerName = (String) teller.get("name");
|
|
||||||
officeId = ((Number) teller.get("office_id")).longValue();
|
|
||||||
} catch (final Exception ignored) {
|
|
||||||
// intentionally ignored
|
|
||||||
}
|
|
||||||
result.put("tellerName", tellerName);
|
|
||||||
|
|
||||||
if (officeId == null) {
|
|
||||||
result.put("hasBreakdown", false);
|
|
||||||
result.put("totalAmount", BigDecimal.ZERO);
|
|
||||||
result.put("denominations", new ArrayList<>());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
|
|
||||||
// forward day to day, with no cashier's held cash folded in. Fall back to the old
|
|
||||||
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
|
|
||||||
// before the target date (a day closed before vault-only tracking existed).
|
|
||||||
List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
|
||||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
|
||||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
|
||||||
WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?)
|
|
||||||
ORDER BY r.business_date DESC LIMIT 1
|
|
||||||
""", officeId, dateFilter, tellerId);
|
|
||||||
String anchorLeg = "OPENING_VAULT";
|
|
||||||
if (anchorRows.isEmpty()) {
|
|
||||||
// The branch-total OPENING leg mixes the vault with whatever every cashier under it
|
|
||||||
// holds, so it is only a defensible stand-in for a vault when the office has exactly one
|
|
||||||
// teller. With two, EVERY teller without a vault-scoped row of its own would anchor on
|
|
||||||
// the same office-wide breakdown, and the frontend's Branch Total -- which sums each
|
|
||||||
// teller's vault -- would report roughly twice the branch's actual cash. Worse, a close
|
|
||||||
// then snapshots that inflated figure as this teller's CLOSING_VAULT and the next open
|
|
||||||
// carries it forward, making the phantom permanent. A second teller with no history of
|
|
||||||
// its own genuinely holds nothing, and returning no breakdown says so honestly.
|
|
||||||
final Integer tellerCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?",
|
|
||||||
Integer.class, officeId);
|
|
||||||
if (tellerCount != null && tellerCount == 1) {
|
|
||||||
anchorLeg = "OPENING";
|
|
||||||
anchorRows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
|
||||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
|
||||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
|
||||||
WHERE d.register_id = r.id AND d.leg = 'OPENING')
|
|
||||||
ORDER BY r.business_date DESC LIMIT 1
|
|
||||||
""", officeId, dateFilter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anchorRows.isEmpty()) {
|
|
||||||
result.put("hasBreakdown", false);
|
|
||||||
result.put("totalAmount", BigDecimal.ZERO);
|
|
||||||
result.put("denominations", new ArrayList<>());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
|
|
||||||
final String anchorDate = anchorRows.get(0).get("business_date").toString();
|
|
||||||
|
|
||||||
final Map<Long, long[]> balance = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
|
|
||||||
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
|
|
||||||
WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
|
|
||||||
""", DENOM_ROW, registerId, anchorLeg, tellerId), 1);
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
|
|
||||||
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
|
||||||
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
|
|
||||||
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
|
|
||||||
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
|
|
||||||
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
|
|
||||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
|
|
||||||
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
|
||||||
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
|
|
||||||
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
|
|
||||||
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
|
|
||||||
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
|
|
||||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> {
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("denominationId", e.getKey());
|
|
||||||
row.put("denominationValue", (int) e.getValue()[0]);
|
|
||||||
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
|
|
||||||
row.put("count", e.getValue()[2]);
|
|
||||||
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
|
|
||||||
rows.add(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO,
|
|
||||||
BigDecimal::add);
|
|
||||||
|
|
||||||
result.put("hasBreakdown", true);
|
|
||||||
result.put("anchorDate", anchorDate);
|
|
||||||
result.put("totalAmount", totalAmount);
|
|
||||||
result.put("denominations", rows);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
|
|
||||||
for (final Object[] row : rows) {
|
|
||||||
final Long denomId = (Long) row[0];
|
|
||||||
final int denomValue = (Integer) row[1];
|
|
||||||
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
|
|
||||||
final long count = (Long) row[3];
|
|
||||||
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
|
|
||||||
entry[2] += count * sign;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-81
@@ -22,9 +22,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.portfolio.cashreport.api.CashReportApiResource;
|
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
@@ -33,8 +31,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
|
|||||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
||||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
@@ -45,7 +41,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
@Path("/v1/cashsettlement")
|
@Path("/v1/cashsettlement")
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class CashSettlementApiResource {
|
public class CashSettlementApiResource {
|
||||||
@@ -57,22 +52,9 @@ public class CashSettlementApiResource {
|
|||||||
private final PlatformTransactionManager transactionManager;
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VoucherService voucherService;
|
|
||||||
// The same figure the Cashier Position screen shows, so a cashier cannot settle notes the report
|
|
||||||
// says they are not holding.
|
|
||||||
private final CashReportApiResource cashReportApiResource;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// Transactional so the reconciliation precondition and the duplicate-settlement check below are
|
|
||||||
// read in the same transaction that writes the settlement. They used to run in autocommit while
|
|
||||||
// the insert happened later inside its own TransactionTemplate, leaving a window where a
|
|
||||||
// double-clicked Submit passed both checks twice and wrote two settlements for the same
|
|
||||||
// cashier/date -- each subtracted cumulatively, so the drawer read permanently negative by the
|
|
||||||
// settlement amount. Migration 0540 adds the unique index that makes the race impossible rather
|
|
||||||
// than merely unlikely; this annotation is what stops the two halves of the check-then-act from
|
|
||||||
// being separated by another request's commit.
|
|
||||||
@POST
|
@POST
|
||||||
@Transactional
|
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
|
||||||
|
|
||||||
@@ -125,8 +107,6 @@ public class CashSettlementApiResource {
|
|||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
validateCashierHoldsDenominations(fromCashierId, denominations, settlementDate);
|
|
||||||
|
|
||||||
final BigDecimal total = sumTotal(denominations);
|
final BigDecimal total = sumTotal(denominations);
|
||||||
|
|
||||||
CashSettlement header = new CashSettlement();
|
CashSettlement header = new CashSettlement();
|
||||||
@@ -305,67 +285,6 @@ public class CashSettlementApiResource {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refuses a hand-over of notes the cashier is not holding.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Nothing stopped this before, and the damage is not a rejected screen but a drawer that reads
|
|
||||||
* negative for the rest of its life: a settlement is subtracted from the cashier's position
|
|
||||||
* cumulatively, so one settlement of cash they never had leaves every later position short by
|
|
||||||
* that amount, on every date, with no transaction to point at. This database carries exactly
|
|
||||||
* that — ₹10.5 lakh settled against ₹26 thousand ever allocated, and a Cashier Position screen
|
|
||||||
* showing counts like -1814 as a result.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Checked per denomination, not on the total, for the same reason the vault side is: a drawer
|
|
||||||
* holding ₹600 in one ₹500 and one ₹100 cannot hand over six ₹100 notes, though the total
|
|
||||||
* agrees. The holding comes from {@code buildPositionForCashier} — the figure the Cashier
|
|
||||||
* Position screen shows — so what a cashier is told they hold is what they are allowed to settle.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A cashier with no position at all is left alone: a first settlement on a drawer whose
|
|
||||||
* allocations were recorded without a breakdown has nothing to check against, and blocking it
|
|
||||||
* would stop a branch settling at all rather than prevent a mistake.
|
|
||||||
*/
|
|
||||||
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
|
||||||
final LocalDate settlementDate) {
|
|
||||||
|
|
||||||
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(fromCashierId,
|
|
||||||
settlementDate.toString());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
|
||||||
if (heldRows == null || heldRows.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<Long, Long> held = new java.util.HashMap<>();
|
|
||||||
for (final Map<String, Object> row : heldRows) {
|
|
||||||
final Object id = row.get("denominationId");
|
|
||||||
final Object count = row.get("count");
|
|
||||||
if (id instanceof Number && count instanceof Number) {
|
|
||||||
held.merge(((Number) id).longValue(), ((Number) count).longValue(), Long::sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final JsonElement el : denominations) {
|
|
||||||
final JsonObject item = el.getAsJsonObject();
|
|
||||||
final long count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsLong() : 0;
|
|
||||||
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long denominationId = item.get("denominationId").getAsLong();
|
|
||||||
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
|
||||||
? item.get("denominationValue").getAsInt() : 0;
|
|
||||||
final long available = held.getOrDefault(denominationId, 0L);
|
|
||||||
if (count > available) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.insufficient.cashier.denomination",
|
|
||||||
"Cashier is not holding that much of denomination " + denominationValue + ": has " + available
|
|
||||||
+ ", settling " + count + ". Check the Cashier Position report before settling.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasPositiveCount(JsonArray items) {
|
private boolean hasPositiveCount(JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
|
||||||
|
|||||||
-120
@@ -24,7 +24,6 @@ import java.util.stream.Collectors;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
|
||||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
|
||||||
@@ -53,9 +52,6 @@ public class CashTransferApiResource {
|
|||||||
private final PlatformTransactionManager transactionManager;
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
// The same source the Cash Transfer screen reads its Available column from, so what is offered
|
|
||||||
// and what is checked cannot disagree.
|
|
||||||
private final VaultPositionService vaultPositionService;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -102,8 +98,6 @@ public class CashTransferApiResource {
|
|||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
|
||||||
|
|
||||||
CashTransfer header = new CashTransfer();
|
CashTransfer header = new CashTransfer();
|
||||||
header.setTransferDate(transferDate);
|
header.setTransferDate(transferDate);
|
||||||
header.setFromParty(fromParty);
|
header.setFromParty(fromParty);
|
||||||
@@ -270,120 +264,6 @@ public class CashTransferApiResource {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
|
|
||||||
* branch's business day carries an opening_cash figure forward each day (see
|
|
||||||
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
|
|
||||||
* that opening figure, plus anything a cashier has already settled back into this same vault
|
|
||||||
* today, minus whatever this vault has already handed out to any cashier today. Reversed
|
|
||||||
* transfers/settlements don't count.
|
|
||||||
*
|
|
||||||
* No open business day for the vault's office means there is no opening_cash to check against, so
|
|
||||||
* the transfer is blocked outright rather than silently allowed against an unknown balance.
|
|
||||||
*/
|
|
||||||
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
|
|
||||||
final JsonArray denominations, final LocalDate transferDate) {
|
|
||||||
final Long officeId;
|
|
||||||
try {
|
|
||||||
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
|
|
||||||
"Vault/teller " + fromTellerId + " was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
|
||||||
|
|
||||||
final List<Long> registerIds = jdbcTemplate.query("""
|
|
||||||
SELECT id FROM m_branch_day_register
|
|
||||||
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
|
|
||||||
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
|
|
||||||
if (registerIds.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
|
|
||||||
"The business day for this branch is not open for " + businessDate
|
|
||||||
+ ". Open the business day before allocating cash to a cashier.");
|
|
||||||
}
|
|
||||||
final Long registerId = registerIds.get(0);
|
|
||||||
|
|
||||||
final BigDecimal openingCash = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
|
|
||||||
|
|
||||||
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
|
||||||
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
|
|
||||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
|
||||||
|
|
||||||
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
|
||||||
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
|
|
||||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
|
||||||
|
|
||||||
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
|
|
||||||
if (requestedAmount.compareTo(available) > 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
|
|
||||||
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
|
||||||
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
|
||||||
* total is well within its balance. This checks each requested denomination against what the
|
|
||||||
* vault actually holds of it.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The holding comes from {@link VaultPositionService}, the same figure the Cash Transfer screen
|
|
||||||
* shows in its Available column. It has to: this check used to walk the register itself and read
|
|
||||||
* the branch-total {@code OPENING} leg, which mixes the vault with whatever every cashier under
|
|
||||||
* it is holding and is anchored on a different day. The screen offered a ₹100 note the vault
|
|
||||||
* plainly had and the transfer was then refused as "vault has 0" — two answers to one question,
|
|
||||||
* from two sources. Reading the service means the number that was offered is the number that is
|
|
||||||
* checked, and neither can drift from the other again.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A day with no breakdown recorded anywhere has nothing to check a denomination against, so this
|
|
||||||
* is skipped rather than blocking every transfer on a day that simply never captured one.
|
|
||||||
*/
|
|
||||||
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
|
|
||||||
final JsonArray denominations, final java.sql.Date businessDate) {
|
|
||||||
final Map<String, Object> position = vaultPositionService.buildPositionForVault(fromTellerId, businessDate.toString());
|
|
||||||
if (!Boolean.TRUE.equals(position.get("hasBreakdown"))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<Long, Integer> available = new java.util.HashMap<>();
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
|
||||||
if (heldRows != null) {
|
|
||||||
for (final Map<String, Object> row : heldRows) {
|
|
||||||
final Object id = row.get("denominationId");
|
|
||||||
final Object count = row.get("count");
|
|
||||||
if (id instanceof Number && count instanceof Number) {
|
|
||||||
available.merge(((Number) id).longValue(), ((Number) count).intValue(), Integer::sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final JsonElement el : denominations) {
|
|
||||||
final JsonObject item = el.getAsJsonObject();
|
|
||||||
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
|
||||||
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long denominationId = item.get("denominationId").getAsLong();
|
|
||||||
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
|
||||||
? item.get("denominationValue").getAsInt() : 0;
|
|
||||||
final int availableCount = available.getOrDefault(denominationId, 0);
|
|
||||||
if (count > availableCount) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
|
|
||||||
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
|
|
||||||
+ ", requested " + count + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasPositiveCount(JsonArray items) {
|
private boolean hasPositiveCount(JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
|
|||||||
-970
@@ -1,970 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
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.PathParam;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
|
||||||
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.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
|
|
||||||
*
|
|
||||||
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
|
|
||||||
* flow.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Cash at Vault (SHARES_REFERENCE) = amount
|
|
||||||
* Cr Share Sundry (SHARES_SUSPENSE) = amount
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
|
|
||||||
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
|
|
||||||
* Cr Share Capital (SHARES_EQUITY) = share amount
|
|
||||||
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
|
|
||||||
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
|
|
||||||
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
|
|
||||||
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>When the cash does not match</b>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
|
|
||||||
* argued with at the window:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
|
|
||||||
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
|
|
||||||
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
|
|
||||||
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
|
|
||||||
* back to them.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@Path("/v1/shareadvancecollection")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class ShareAdvanceCollectionApiResource {
|
|
||||||
|
|
||||||
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
|
|
||||||
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
|
|
||||||
private static final int DEBIT = 2;
|
|
||||||
private static final int CREDIT = 1;
|
|
||||||
|
|
||||||
// ── CashAccountsForShares financial_account_type ───────────────────────
|
|
||||||
private static final int FA_SHARES_REFERENCE = 1;
|
|
||||||
private static final int FA_SHARES_SUSPENSE = 2;
|
|
||||||
private static final int FA_INCOME_FROM_FEES = 3;
|
|
||||||
private static final int FA_SHARES_EQUITY = 4;
|
|
||||||
|
|
||||||
// ── acc_product_mapping.product_type for shares ────────────────────────
|
|
||||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
|
||||||
|
|
||||||
// ── m_share_account.status_enum ───────────────────────────────────────
|
|
||||||
private static final int STATUS_ACTIVE = 300;
|
|
||||||
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST — Take cash from a member with no share account yet
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@POST
|
|
||||||
@Transactional
|
|
||||||
public Response collect(final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final Long clientId = getLong(json, "clientId");
|
|
||||||
final BigDecimal amount = getBigDecimal(json, "amount");
|
|
||||||
final String note = sanitiseNote(getString(json, "note"));
|
|
||||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
|
||||||
|
|
||||||
if (clientId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
|
|
||||||
}
|
|
||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> client = loadClient(clientId);
|
|
||||||
final Long officeId = toLong(client.get("office_id"));
|
|
||||||
final String clientName = (String) client.get("display_name");
|
|
||||||
|
|
||||||
// The share product drives the GL mapping. Collection happens before an account exists,
|
|
||||||
// so the product is supplied by the caller or defaults to the only mapped share product.
|
|
||||||
final Long productId = resolveProductId(getLong(json, "productId"));
|
|
||||||
final String currencyCode = resolveCurrency(productId);
|
|
||||||
|
|
||||||
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
|
|
||||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
|
|
||||||
final long userId = currentUser.getId();
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final String txnId = "SHADV-" + System.nanoTime();
|
|
||||||
final String receiptNo = nextReceiptNo();
|
|
||||||
|
|
||||||
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
|
|
||||||
|
|
||||||
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
|
|
||||||
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
|
|
||||||
"Share advance held in Sundry | Receipt " + receiptNo);
|
|
||||||
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO m_share_advance_collection "
|
|
||||||
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
|
||||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
|
||||||
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
|
|
||||||
currentUser.getUsername(), note);
|
|
||||||
|
|
||||||
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
|
|
||||||
receiptNo);
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("collectionId", id);
|
|
||||||
out.put("receiptNo", receiptNo);
|
|
||||||
out.put("clientId", clientId);
|
|
||||||
out.put("clientName", clientName);
|
|
||||||
out.put("amount", amount);
|
|
||||||
out.put("currency", currencyCode);
|
|
||||||
out.put("status", "UNAPPLIED");
|
|
||||||
out.put("glTransactionId", txnId);
|
|
||||||
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — Unapplied receipts, optionally for one client
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@GET
|
|
||||||
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
|
|
||||||
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
|
|
||||||
if (clientId != null) {
|
|
||||||
sql.append(" AND c.client_id = ").append(clientId);
|
|
||||||
}
|
|
||||||
sql.append(" ORDER BY c.collection_date, c.id");
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("unappliedCollections", rows);
|
|
||||||
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — One unapplied receipt by the voucher number quoted by the member
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
|
|
||||||
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
|
||||||
// firewall with a 400 before the request reaches this resource. The older
|
|
||||||
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
|
|
||||||
@GET
|
|
||||||
@Path("/by-voucher")
|
|
||||||
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
|
|
||||||
@QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) {
|
|
||||||
if (voucherNo == null || voucherNo.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
|
|
||||||
"A voucher or receipt number is required.");
|
|
||||||
}
|
|
||||||
return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/by-voucher/{voucherNo}")
|
|
||||||
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
|
|
||||||
@QueryParam("glAccountNo") final String glAccountNoOverride) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate
|
|
||||||
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
|
||||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
|
|
||||||
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
|
|
||||||
|
|
||||||
if (rows.isEmpty()) {
|
|
||||||
// Cash is normally taken through the cashier's Transaction Entry deposit into the GL
|
|
||||||
// account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
|
|
||||||
// that approved deposit as a collection so the member's voucher resolves here. Which GL
|
|
||||||
// account to look in is never guessed from product configuration — the counter selects it
|
|
||||||
// by hand every time, since a product mapping can silently go missing or point at the
|
|
||||||
// wrong account and a plain voucher lookup should never depend on that being right.
|
|
||||||
Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
|
|
||||||
if (adopted == null) {
|
|
||||||
// A member can also fund the same GL account through the Clerk's Disbursement Request
|
|
||||||
// register (a COLLECTION request debits the member's own savings account and credits a
|
|
||||||
// GL account, approved the same way a withdrawal is) — a second, equally legitimate way
|
|
||||||
// for money to reach Share Sundry / a fee-income account without a cashier counter
|
|
||||||
// deposit ever being raised.
|
|
||||||
adopted = adoptApprovedClerkDisbursementCollection(voucherNo, glAccountNoOverride);
|
|
||||||
}
|
|
||||||
if (adopted != null) {
|
|
||||||
return Response.ok(gson.toJson(adopted)).build();
|
|
||||||
}
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
|
|
||||||
+ voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the "
|
|
||||||
+ "deposit has been approved by a manager.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> row = rows.get(0);
|
|
||||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
|
||||||
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
|
|
||||||
}
|
|
||||||
return Response.ok(gson.toJson(row)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — GL accounts selectable for a by-voucher lookup
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
/**
|
|
||||||
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
|
|
||||||
*
|
|
||||||
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
|
|
||||||
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
|
|
||||||
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
|
|
||||||
* applying a receipt picks the account the deposit actually went to, every time.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("/glaccounts")
|
|
||||||
public Response glAccounts() {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("glAccounts", rows);
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — What a share account needs before a collection can be applied
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
/**
|
|
||||||
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
|
|
||||||
* Collection screen so the person at the counter knows the full amount to collect (and that it may
|
|
||||||
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
|
|
||||||
* before a search ever comes up short, rather than learning it only from an error after the fact.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("/requirement")
|
|
||||||
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
if (shareAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
|
||||||
}
|
|
||||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
|
||||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
|
||||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("accountNo", acct.get("account_no"));
|
|
||||||
out.put("shareAmount", shareAmount);
|
|
||||||
out.put("chargeAmount", chargeAmount);
|
|
||||||
out.put("required", shareAmount.add(chargeAmount));
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST — Apply a receipt to an activated share account
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@POST
|
|
||||||
@Path("/{collectionId}/apply")
|
|
||||||
@Transactional
|
|
||||||
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
|
||||||
if (shareAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A member who pays in instalments has more than one receipt against the same application:
|
|
||||||
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
|
|
||||||
// are applied together as one settlement. The path receipt leads; any others come in the
|
|
||||||
// body. These are the SHARE-side receipts only -- money that actually sat in Sundry and
|
|
||||||
// genuinely needs to move out of it into Share Capital.
|
|
||||||
final List<Long> collectionIds = new java.util.ArrayList<>();
|
|
||||||
collectionIds.add(collectionId);
|
|
||||||
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
|
|
||||||
if (!collectionIds.contains(additionalId)) {
|
|
||||||
collectionIds.add(additionalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
|
||||||
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
|
|
||||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
|
||||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
|
||||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
|
||||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
|
||||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
|
||||||
|
|
||||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
|
||||||
for (final Long id : collectionIds) {
|
|
||||||
final Map<String, Object> row = loadCollection(id);
|
|
||||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
|
||||||
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
|
|
||||||
}
|
|
||||||
collections.add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> chargeColl = null;
|
|
||||||
if (chargeCollectionId != null) {
|
|
||||||
chargeColl = loadCollection(chargeCollectionId);
|
|
||||||
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
|
||||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> coll = collections.get(0);
|
|
||||||
|
|
||||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
|
||||||
|
|
||||||
// Every receipt belongs to the member: applying one to someone else's account would move
|
|
||||||
// one member's money into another's share capital. Checked per receipt, since a settlement
|
|
||||||
// can now carry several.
|
|
||||||
final Long acctClientId = toLong(acct.get("client_id"));
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
if (!toLong(row.get("client_id")).equals(acctClientId)) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
|
|
||||||
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
|
|
||||||
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
final int statusEnum = toInt(acct.get("status_enum"));
|
|
||||||
if (statusEnum != STATUS_ACTIVE) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
|
|
||||||
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
|
|
||||||
"Share account " + acct.get("account_no") + " has already been settled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share value the account was opened for, and any entry fee attached to it.
|
|
||||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
|
||||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
|
||||||
|
|
||||||
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
|
|
||||||
// cover the share value -- the fee is proved paid separately below, and its money is never
|
|
||||||
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
|
|
||||||
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
|
|
||||||
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount);
|
|
||||||
|
|
||||||
BigDecimal receiptAmount = BigDecimal.ZERO;
|
|
||||||
final StringBuilder receiptNos = new StringBuilder();
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
|
|
||||||
if (receiptNos.length() > 0) {
|
|
||||||
receiptNos.append(" + ");
|
|
||||||
}
|
|
||||||
receiptNos.append(row.get("receipt_no"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short is blocked: share capital cannot be part-issued — the member either bought those
|
|
||||||
// shares or did not — so settling for less would leave the account short by an amount owed
|
|
||||||
// by nobody. The clerk collects the balance on another receipt and applies both together.
|
|
||||||
if (receiptAmount.compareTo(requiredForShareReceipts) < 0) {
|
|
||||||
final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
|
|
||||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
|
|
||||||
+ requiredForShareReceipts + ". Collect the remaining " + shortfall
|
|
||||||
+ " from the member, then apply both receipts together.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
|
|
||||||
// share-side shortfall above, since the two are no longer one combined pot.
|
|
||||||
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
|
|
||||||
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
|
|
||||||
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
|
|
||||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
|
|
||||||
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
|
|
||||||
+ shortfall + " from the member.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
|
|
||||||
// The difference is credited to their linked savings account below. Only the share-side
|
|
||||||
// excess is handled here -- an over-payment on a separately-verified fee receipt would need
|
|
||||||
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
|
|
||||||
// money is left exactly where the cashier already put it), so it is left uncollected rather
|
|
||||||
// than silently unbalancing the ledger.
|
|
||||||
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
|
|
||||||
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
|
|
||||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
|
|
||||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
|
|
||||||
+ " requires only " + requiredForShareReceipts + ". The extra " + excessAmount
|
|
||||||
+ " must be credited to the member's savings account, but none is linked to this share account. Link one, or "
|
|
||||||
+ "collect the exact amount.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = toLong(acct.get("office_id"));
|
|
||||||
final Long productId = toLong(acct.get("product_id"));
|
|
||||||
final String currencyCode = (String) acct.get("currency_code");
|
|
||||||
|
|
||||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
|
||||||
|
|
||||||
final long userId = currentUser.getId();
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
|
||||||
final String txnId = "SHAPP-" + System.nanoTime();
|
|
||||||
final String accountNo = (String) acct.get("account_no");
|
|
||||||
|
|
||||||
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
|
|
||||||
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
|
|
||||||
// it was never in Sundry, so nothing here should debit Sundry for it.
|
|
||||||
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
|
|
||||||
|
|
||||||
// Cr Share Capital for the share value.
|
|
||||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share capital issued for " + accountNo);
|
|
||||||
|
|
||||||
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
|
|
||||||
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
|
|
||||||
// income account itself), so moving it again here would double it. When it was bundled into
|
|
||||||
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
|
|
||||||
// to move, exactly as before.
|
|
||||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (chargeColl == null) {
|
|
||||||
// Resolved from the share product's "Income from Fees" mapping, which points at the
|
|
||||||
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
|
|
||||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
|
||||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share entry fee for " + accountNo);
|
|
||||||
}
|
|
||||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
|
||||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The over-payment goes back to the member as a savings deposit rather than a bare journal
|
|
||||||
// entry, so it lands in their passbook and running balance the way any counter deposit
|
|
||||||
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
|
|
||||||
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
|
|
||||||
Long excessSavingsTransactionId = null;
|
|
||||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
|
|
||||||
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
|
|
||||||
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
|
|
||||||
// table regardless of which GL account they were posted to, so no GL account lookup is needed
|
|
||||||
// here either. Covers the fee receipt's own deposit too, when one was verified.
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
if (row.get("voucher_no") != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
|
|
||||||
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chargeColl != null && chargeColl.get("voucher_no") != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
|
|
||||||
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every receipt in this settlement is marked applied, not just the one in the path — they
|
|
||||||
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
|
|
||||||
// receipt is marked applied too, even though it posted no GL entry of its own.
|
|
||||||
for (final Long id : collectionIds) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
|
||||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
|
|
||||||
}
|
|
||||||
if (chargeCollectionId != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
|
||||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveals the purchase transaction in the passbook and transactions views.
|
|
||||||
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("collectionId", collectionId);
|
|
||||||
out.put("collectionIds", collectionIds);
|
|
||||||
out.put("receiptNo", receiptNos.toString());
|
|
||||||
out.put("shareAccountId", shareAccountId);
|
|
||||||
out.put("accountNo", accountNo);
|
|
||||||
out.put("shareAmount", shareAmount);
|
|
||||||
out.put("chargeAmount", chargeAmount);
|
|
||||||
out.put("chargeReceiptVerifiedSeparately", chargeColl != null);
|
|
||||||
out.put("totalApplied", requiredForShareReceipts);
|
|
||||||
out.put("totalCollected", receiptAmount);
|
|
||||||
out.put("glTransactionId", txnId);
|
|
||||||
out.put("status", "APPLIED");
|
|
||||||
// Told at the counter, while the member is still there, rather than left to be noticed on
|
|
||||||
// a statement later.
|
|
||||||
out.put("excessAmount", excessAmount);
|
|
||||||
if (excessSavingsTransactionId != null) {
|
|
||||||
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
|
|
||||||
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
|
|
||||||
+ "their savings account.");
|
|
||||||
}
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// Helpers
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
|
|
||||||
*
|
|
||||||
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
|
|
||||||
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
|
|
||||||
* so the voucher resolves and can be applied.
|
|
||||||
*
|
|
||||||
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
|
|
||||||
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
|
|
||||||
* is what keeps the money tied to the right member.
|
|
||||||
*
|
|
||||||
* @param glAccountNoOverride
|
|
||||||
* required -- the GL account number the deposit was posted to, selected by the counter
|
|
||||||
* rather than resolved from any product's accounting configuration, so a broken or
|
|
||||||
* missing product mapping can never make a genuine voucher unreachable.
|
|
||||||
* @return the newly created collection row, or null when no approved deposit matches.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
|
|
||||||
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
|
|
||||||
"Select the GL account this voucher was deposited to, then search again.");
|
|
||||||
}
|
|
||||||
final String shareSundryAccountNo = glAccountNoOverride.trim();
|
|
||||||
final List<Map<String, Object>> deposits = jdbcTemplate
|
|
||||||
.queryForList(
|
|
||||||
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
|
|
||||||
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
|
|
||||||
+ " FROM m_cashier_deposit_request d"
|
|
||||||
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
|
|
||||||
voucherNo, shareSundryAccountNo);
|
|
||||||
|
|
||||||
if (deposits.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final Map<String, Object> dep = deposits.get(0);
|
|
||||||
|
|
||||||
if (toLong(dep.get("share_account_id")) != null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
|
||||||
"Voucher " + voucherNo + " has already been applied to a share account.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer the member recorded on the deposit; fall back to the account being settled.
|
|
||||||
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
|
|
||||||
if (clientId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
|
||||||
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
|
|
||||||
+ " so it cannot be matched. Re-enter the deposit against the member.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
|
||||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
|
||||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
|
||||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
|
|
||||||
final String receiptNo = nextReceiptNo();
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO m_share_advance_collection "
|
|
||||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
|
||||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
|
||||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
|
|
||||||
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
|
|
||||||
"Adopted from cashier deposit voucher " + voucherNo);
|
|
||||||
|
|
||||||
return jdbcTemplate.queryForMap(
|
|
||||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
|
||||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
|
||||||
receiptNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopts an approved Clerk Disbursement Request ("COLLECTION" direction) into the Share Sundry GL as a share
|
|
||||||
* advance collection.
|
|
||||||
*
|
|
||||||
* A COLLECTION request debits the member's own savings account and credits a GL account, approved by a manager
|
|
||||||
* the same way a clerk withdrawal is -- a second, equally legitimate way for a member's money to reach Share
|
|
||||||
* Sundry (or a fee-income account) besides a cashier counter deposit. The request itself carries no client id
|
|
||||||
* column; the member is only recorded, as JSON, on the debit leg of {@code credit_entries}.
|
|
||||||
*
|
|
||||||
* @param glAccountNoOverride
|
|
||||||
* required -- same contract as {@link #adoptApprovedDeposit}: the GL account the counter selected,
|
|
||||||
* never guessed from product configuration.
|
|
||||||
* @return the newly created collection row, or null when no approved COLLECTION request matches.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
|
|
||||||
final String glAccountNo = glAccountNoOverride.trim();
|
|
||||||
final List<Map<String, Object>> requests = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at"
|
|
||||||
+ " FROM m_clerk_disbursement_request"
|
|
||||||
+ " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'"
|
|
||||||
+ " ORDER BY id DESC",
|
|
||||||
voucherNo, glAccountNo);
|
|
||||||
if (requests.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final Map<String, Object> req = requests.get(0);
|
|
||||||
|
|
||||||
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
|
|
||||||
if (savingsAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
|
||||||
"Voucher " + voucherNo + " does not debit a member's savings account, so it cannot be matched to a member.");
|
|
||||||
}
|
|
||||||
final Long clientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
|
||||||
savingsAccountId);
|
|
||||||
|
|
||||||
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(clientId);
|
|
||||||
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
|
|
||||||
final Object actionedAt = req.get("actioned_at");
|
|
||||||
final Object submittedAt = req.get("submitted_at");
|
|
||||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
|
||||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
|
||||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
|
|
||||||
final String receiptNo = nextReceiptNo();
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO m_share_advance_collection "
|
|
||||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
|
||||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
|
||||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo,
|
|
||||||
toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo);
|
|
||||||
|
|
||||||
return jdbcTemplate.queryForMap(
|
|
||||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
|
||||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
|
||||||
receiptNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The savings account id named on the debit ("Dr") leg of a Clerk Disbursement Request's {@code credit_entries}
|
|
||||||
* JSON array, or null when that leg names a GL account instead (no member to match against). */
|
|
||||||
private Long debitSavingsAccountId(final String creditEntriesJson) {
|
|
||||||
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final JsonArray entries = JsonParser.parseString(creditEntriesJson).getAsJsonArray();
|
|
||||||
for (final JsonElement el : entries) {
|
|
||||||
final JsonObject entry = el.getAsJsonObject();
|
|
||||||
final boolean isDebit = entry.has("dbCr") && "Dr".equalsIgnoreCase(entry.get("dbCr").getAsString());
|
|
||||||
if (isDebit && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
|
||||||
return entry.get("savingsAccountId").getAsLong();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long loadClientOfficeId(final Long clientId) {
|
|
||||||
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Share value the account was opened for: the pending/approved purchase transaction. */
|
|
||||||
private BigDecimal shareValueOf(final Long shareAccountId) {
|
|
||||||
final BigDecimal total = jdbcTemplate
|
|
||||||
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
|
|
||||||
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
|
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Entry fee still outstanding on the account, zero when no charge was attached. */
|
|
||||||
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
|
|
||||||
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
|
|
||||||
// created before the flat-charge fix carry the flag set to true while the full amount is
|
|
||||||
// still outstanding, and those charges must still be settled by the collection.
|
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
|
|
||||||
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
|
|
||||||
BigDecimal.class, shareAccountId);
|
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadCollection(final Long id) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
|
|
||||||
"Share advance collection " + id + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadClient(final Long clientId) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadAccount(final Long shareAccountId) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
|
|
||||||
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
|
|
||||||
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
|
|
||||||
"Share account " + shareAccountId + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the caller's product when given, else the single mapped share product.
|
|
||||||
*
|
|
||||||
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
|
|
||||||
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
|
|
||||||
* when only one real share product is mapped.
|
|
||||||
*/
|
|
||||||
private Long resolveProductId(final Long requested) {
|
|
||||||
if (requested != null) {
|
|
||||||
return requested;
|
|
||||||
}
|
|
||||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
|
|
||||||
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
|
|
||||||
if (ids.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
|
|
||||||
"No share product has GL mappings configured.");
|
|
||||||
}
|
|
||||||
if (ids.size() > 1) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
|
|
||||||
"productId is required: " + ids.size() + " share products have GL mappings.");
|
|
||||||
}
|
|
||||||
return ids.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveCurrency(final Long productId) {
|
|
||||||
try {
|
|
||||||
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
|
|
||||||
productId);
|
|
||||||
return code == null ? "INR" : code;
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
return "INR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
|
||||||
final Long glId = resolveGl(productId, financialAccountType);
|
|
||||||
if (glId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
|
|
||||||
label + " GL is not mapped for share product " + productId + ".");
|
|
||||||
}
|
|
||||||
return glId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long resolveGl(final Long productId, final int financialAccountType) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT gl_account_id FROM acc_product_mapping "
|
|
||||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
|
||||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
|
||||||
} catch (IncorrectResultSizeDataAccessException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String nextReceiptNo() {
|
|
||||||
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
|
|
||||||
return String.format("SHADV%06d", seq == null ? 1 : seq);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
|
||||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
|
||||||
final 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, entity_id, "
|
|
||||||
+ " 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, shareAccountId,
|
|
||||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
|
||||||
*
|
|
||||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
|
||||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
|
||||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
|
||||||
*/
|
|
||||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
|
||||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
|
||||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonObject parseBody(final String body) {
|
|
||||||
if (body == null || body.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JsonParser.parseString(body).getAsJsonObject();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDate parseDate(final String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return LocalDate.parse(raw);
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
|
|
||||||
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getLong(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
|
|
||||||
private List<Long> getLongList(final JsonObject json, final String field) {
|
|
||||||
final List<Long> values = new java.util.ArrayList<>();
|
|
||||||
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
|
|
||||||
if (!element.isJsonNull()) {
|
|
||||||
values.add(element.getAsLong());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credits an over-payment to the member's savings account.
|
|
||||||
*
|
|
||||||
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
|
|
||||||
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
|
|
||||||
* would move the balance in the ledger while leaving the member's own account history silent about it.
|
|
||||||
*/
|
|
||||||
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
|
||||||
payload.addProperty("transactionDate", txnDate.toString());
|
|
||||||
payload.addProperty("transactionAmount", amount);
|
|
||||||
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
|
|
||||||
payload.addProperty("note", note);
|
|
||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
|
|
||||||
.build();
|
|
||||||
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The payment type this deposit is recorded under.
|
|
||||||
*
|
|
||||||
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
|
|
||||||
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
|
|
||||||
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
|
|
||||||
*/
|
|
||||||
private Long resolveExcessPaymentTypeId() {
|
|
||||||
final List<Long> byName = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
|
|
||||||
if (!byName.isEmpty()) {
|
|
||||||
return byName.get(0);
|
|
||||||
}
|
|
||||||
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
|
|
||||||
if (any.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
|
|
||||||
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
|
|
||||||
}
|
|
||||||
return any.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long toLong(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int toInt(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).intValue() : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-16
@@ -21,7 +21,6 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -361,7 +360,6 @@ public class ShareCollectionApiResource {
|
|||||||
final LocalDate entryDate, final Long shareAccountId,
|
final LocalDate entryDate, final Long shareAccountId,
|
||||||
final long userId, final LocalDateTime now, final String currencyCode,
|
final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
final String description) {
|
final String description) {
|
||||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"INSERT INTO acc_gl_journal_entry "
|
"INSERT INTO acc_gl_journal_entry "
|
||||||
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
@@ -377,20 +375,8 @@ public class ShareCollectionApiResource {
|
|||||||
userId, userId, now, now,
|
userId, userId, now, now,
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
toUtc(now),
|
now.atOffset(ZoneOffset.UTC),
|
||||||
toUtc(now));
|
now.atOffset(ZoneOffset.UTC));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
|
||||||
*
|
|
||||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
|
||||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
|
||||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
|
||||||
*/
|
|
||||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
|
||||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
|
||||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> glEntryMap(final String account, final String type,
|
private Map<String, Object> glEntryMap(final String account, final String type,
|
||||||
|
|||||||
+1
-55
@@ -35,9 +35,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
@@ -92,12 +89,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
||||||
private final SavingsHelper savingsHelper;
|
private final SavingsHelper savingsHelper;
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final VoucherService voucherService;
|
|
||||||
|
|
||||||
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
|
|
||||||
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
|
|
||||||
|
|
||||||
|
|
||||||
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
||||||
private FacilityChargeMapping resolveMapping() {
|
private FacilityChargeMapping resolveMapping() {
|
||||||
@@ -253,7 +244,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId);
|
||||||
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
}
|
}
|
||||||
@@ -288,47 +278,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
this.currentAccountRepository.saveAndFlush(account);
|
this.currentAccountRepository.saveAndFlush(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a cheque book issuance charge in the voucher register.
|
|
||||||
*
|
|
||||||
* The account is debited and the charge's income account credited — the society's fee, taken
|
|
||||||
* from the customer. The debit names the customer and their account number even though it posts
|
|
||||||
* against the product's control GL, so the slip reads as the customer's own.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the charge. The money is already taken and the ledger already
|
|
||||||
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
|
|
||||||
* problem is logged and the charge stands.
|
|
||||||
*/
|
|
||||||
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
|
|
||||||
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
|
|
||||||
try {
|
|
||||||
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
|
|
||||||
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
|
|
||||||
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
|
|
||||||
|
|
||||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
|
|
||||||
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
|
|
||||||
List.of(VoucherLine
|
|
||||||
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
|
|
||||||
.withDescription(narration),
|
|
||||||
VoucherLine
|
|
||||||
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
|
|
||||||
.withDescription(narration)));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The customer and account number a charge is taken from, for printing on a voucher. */
|
|
||||||
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
|
|
||||||
final String sql = accountType == ChequeAccountType.SAVINGS
|
|
||||||
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
|
|
||||||
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
|
||||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
||||||
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
||||||
@@ -371,10 +320,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// The charge raises its own voucher, naming the account charged and the fee; the journal
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
// command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = this.voucherService
|
|
||||||
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
-133
@@ -1,133 +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.clearing.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
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.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing
|
|
||||||
* role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are
|
|
||||||
* fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads
|
|
||||||
* them and updates which GL account one points at.
|
|
||||||
*/
|
|
||||||
@Path("/v1/clearingglconfig")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class ClearingGlConfigApiResource {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final FromJsonHelper fromJsonHelper;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public String retrieveAll() {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code
|
|
||||||
FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id
|
|
||||||
ORDER BY c.config_key
|
|
||||||
""");
|
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> row : rows) {
|
|
||||||
out.add(toResult(row));
|
|
||||||
}
|
|
||||||
return gson.toJson(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("template")
|
|
||||||
public String template() {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
|
|
||||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
|
||||||
SELECT id, name, gl_code FROM acc_gl_account
|
|
||||||
WHERE disabled = false AND manual_journal_entries_allowed = true
|
|
||||||
ORDER BY name
|
|
||||||
""");
|
|
||||||
final List<Map<String, Object>> options = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> a : accounts) {
|
|
||||||
final Map<String, Object> o = new LinkedHashMap<>();
|
|
||||||
o.put("id", a.get("id"));
|
|
||||||
o.put("name", a.get("name"));
|
|
||||||
o.put("glCode", a.get("gl_code"));
|
|
||||||
options.add(o);
|
|
||||||
}
|
|
||||||
return gson.toJson(Map.of("glAccountOptions", options));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PUT
|
|
||||||
@Path("{configKey}")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
public String update(@PathParam("configKey") final String configKey, final String body) {
|
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG");
|
|
||||||
|
|
||||||
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?",
|
|
||||||
Integer.class, configKey);
|
|
||||||
if (exists == null || exists == 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found",
|
|
||||||
"Clearing GL config key '" + configKey + "' was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final var root = this.fromJsonHelper.parse(body);
|
|
||||||
final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root);
|
|
||||||
|
|
||||||
if (glAccountId != null) {
|
|
||||||
final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?",
|
|
||||||
Integer.class, glAccountId);
|
|
||||||
if (accountExists == null || accountExists == 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found",
|
|
||||||
"GL account " + glAccountId + " was not found.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId,
|
|
||||||
configKey);
|
|
||||||
|
|
||||||
return retrieveAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> toResult(final Map<String, Object> row) {
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("id", row.get("id"));
|
|
||||||
out.put("configKey", row.get("config_key"));
|
|
||||||
out.put("description", row.get("description"));
|
|
||||||
out.put("glAccountId", row.get("gl_account_id"));
|
|
||||||
out.put("glAccountName", row.get("gl_account_name"));
|
|
||||||
out.put("glCode", row.get("gl_code"));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-94
@@ -24,7 +24,6 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
@@ -33,9 +32,6 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -53,11 +49,6 @@ public class ClearingJournalService {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final VoucherService voucherService;
|
|
||||||
|
|
||||||
/** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */
|
|
||||||
private static final String REF_CLEARING_POSTING = "CLEARING_POSTING";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
|
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
|
||||||
@@ -66,19 +57,6 @@ public class ClearingJournalService {
|
|||||||
*/
|
*/
|
||||||
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
|
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
|
||||||
return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT.
|
|
||||||
*
|
|
||||||
* Savings and current accounts number independently, so the same id exists in both tables and the id
|
|
||||||
* alone cannot say whose account it is. A caller that knows the type passes it and the voucher names
|
|
||||||
* the right customer; one that has no account at all — a settlement summary — passes neither.
|
|
||||||
*/
|
|
||||||
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
|
||||||
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId,
|
|
||||||
final String linkAccountType) {
|
|
||||||
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
|
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
|
||||||
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
||||||
creditGlId, amount);
|
creditGlId, amount);
|
||||||
@@ -108,10 +86,7 @@ public class ClearingJournalService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// Clearing raises its own voucher below, naming the customer account the item belongs to;
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
// the journal command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = this.voucherService
|
|
||||||
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
||||||
return null;
|
return null;
|
||||||
@@ -120,77 +95,9 @@ public class ClearingJournalService {
|
|||||||
if (glTxnId != null && linkAccountId != null) {
|
if (glTxnId != null && linkAccountId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
||||||
}
|
}
|
||||||
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
|
||||||
return glTxnId;
|
return glTxnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records one clearing posting in the voucher register.
|
|
||||||
*
|
|
||||||
* Raised here rather than in each caller because CTS items, NACH movements and settlement
|
|
||||||
* summaries all post through this one path: a voucher per caller would be the same slip written
|
|
||||||
* three times, and one of them would drift.
|
|
||||||
*
|
|
||||||
* The caller's comment carries what the posting is — the item, the movement, the batch — so it
|
|
||||||
* becomes the narration on both sides. Where the caller named a customer account, the debit line
|
|
||||||
* carries that customer and account number; a settlement summary names no account and its lines
|
|
||||||
* stand on the GL names alone, which is what a summary is.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the posting. The entry is already in the ledger, and clearing runs
|
|
||||||
* over batches where one unwritable slip must not take a whole settlement down with it. The
|
|
||||||
* problem is logged and the posting stands.
|
|
||||||
*/
|
|
||||||
private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
|
|
||||||
final String comments, final Long linkAccountId, final String linkAccountType) {
|
|
||||||
if (glTxnId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final Map<String, Object> debitGl = this.voucherService.glAccount(debitGlId);
|
|
||||||
final Map<String, Object> creditGl = this.voucherService.glAccount(creditGlId);
|
|
||||||
final Map<String, Object> holder = accountHolderForVoucher(linkAccountId, linkAccountType);
|
|
||||||
|
|
||||||
// The debit is the side a customer account is named against, matching the row this
|
|
||||||
// posting links to the account for reporting.
|
|
||||||
final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name");
|
|
||||||
final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no")
|
|
||||||
: (String) debitGl.get("gl_code");
|
|
||||||
|
|
||||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId,
|
|
||||||
this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL,
|
|
||||||
List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments),
|
|
||||||
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
|
||||||
.withDescription(comments)));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The customer and account number a clearing item belongs to, for printing on a voucher.
|
|
||||||
*
|
|
||||||
* The type decides which table to read: savings and current accounts number independently, so
|
|
||||||
* the same id exists in both and looking in the wrong one would put another customer's name on
|
|
||||||
* the slip. Empty when the caller named no account, as a settlement summary does, or named one
|
|
||||||
* without saying which kind — a wrong name is worse on a voucher than no name.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> accountHolderForVoucher(final Long accountId, final String accountType) {
|
|
||||||
if (accountId == null || accountType == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
final String sql;
|
|
||||||
if ("SAVINGS".equalsIgnoreCase(accountType)) {
|
|
||||||
sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?";
|
|
||||||
} else if ("CURRENT".equalsIgnoreCase(accountType)) {
|
|
||||||
sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
|
||||||
} else {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
||||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
||||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
||||||
|
|||||||
+11
-166
@@ -46,7 +46,6 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -66,8 +65,6 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
|||||||
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -83,7 +80,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
public class ClerkDisbursementRequestApiResource {
|
public class ClerkDisbursementRequestApiResource {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final KycRestrictionGuard kycRestrictionGuard;
|
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -113,7 +109,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
||||||
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
|
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
|
||||||
if (requestType == null || requestType.isBlank()
|
if (requestType == null || requestType.isBlank()
|
||||||
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
|
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
|
||||||
requestType = "DISBURSEMENT";
|
requestType = "DISBURSEMENT";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,14 +141,6 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
||||||
throw apiError(400, "Each entry must have an account number");
|
throw apiError(400, "Each entry must have an account number");
|
||||||
}
|
}
|
||||||
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
|
|
||||||
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
|
|
||||||
// working, since nothing about a restricted customer should prevent them being paid.
|
|
||||||
// A GL leg carries no savingsAccountId and the guard ignores it.
|
|
||||||
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
|
||||||
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
|
|
||||||
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
|
|
||||||
}
|
|
||||||
if ("Dr".equals(dbCr)) {
|
if ("Dr".equals(dbCr)) {
|
||||||
totalDr = totalDr.add(entryAmt);
|
totalDr = totalDr.add(entryAmt);
|
||||||
} else {
|
} else {
|
||||||
@@ -197,27 +185,9 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
activityLog, Timestamp.from(Instant.now()), requestType
|
activityLog, Timestamp.from(Instant.now()), requestType
|
||||||
);
|
);
|
||||||
|
|
||||||
// A collection pulls money into the GL, so it reads as a credit; a disbursement moves
|
|
||||||
// money out of the source side, so it reads as a debit — but only when the TXN TP. the
|
|
||||||
// clerk actually picked is Cash. That dropdown is the real-world signal for whether notes
|
|
||||||
// cross the counter, the same way it already governs every teller screen in this system
|
|
||||||
// (isCashPayment): a clerk who selects Transfer, Cheque, DD or NEFT/RTGS is saying no cash
|
|
||||||
// moves for this request, whatever GL it happens to post against, so it belongs in the
|
|
||||||
// Journal series instead of being counted as real cash received or paid on My Vouchers.
|
|
||||||
final boolean isCashPayment = paymentTypeId != null && Boolean.TRUE.equals(jdbcTemplate.queryForObject(
|
|
||||||
"SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId));
|
|
||||||
final String voucherPrefix = !isCashPayment ? VoucherService.PREFIX_JOURNAL
|
|
||||||
: "COLLECTION".equals(requestType) ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
|
||||||
// The voucher's own TYPE column used to be hard-coded to VoucherService.TYPE_DISBURSEMENT
|
|
||||||
// for every request this register raises, so My Vouchers showed "DISBURSEMENT" even for a
|
|
||||||
// Collection or a Fund Transfer the clerk had explicitly picked a different TYPE for on the
|
|
||||||
// entry screen — the one place this register's actual four-way distinction ought to have
|
|
||||||
// been visible was throwing it away. Stored as the clerk's own requestType instead, in the
|
|
||||||
// same words the TYPE dropdown already uses.
|
|
||||||
final String voucherTypeLabel = voucherTypeLabelFor(requestType);
|
|
||||||
final String voucherNo = voucherService.createVoucher(
|
final String voucherNo = voucherService.createVoucher(
|
||||||
voucherTypeLabel, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||||
newId, txnAmount, currentUser, voucherPrefix);
|
newId, txnAmount, currentUser);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||||
|
|
||||||
@@ -574,7 +544,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return ps;
|
return ps;
|
||||||
}, btLogKh);
|
}, btLogKh);
|
||||||
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
||||||
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
|
final String btVoucherNo = generateBtVoucherNo(btLogId);
|
||||||
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
|
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,11 +715,6 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip, built from the entries as approved. Only reached once every
|
|
||||||
// posting above has succeeded, so the voucher describes a completed movement rather than
|
|
||||||
// one that was attempted.
|
|
||||||
addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType);
|
|
||||||
|
|
||||||
final String txnRefsStr = String.join(",", txnRefs);
|
final String txnRefsStr = String.join(",", txnRefs);
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||||
@@ -1016,7 +981,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return ps;
|
return ps;
|
||||||
}, logKh);
|
}, logKh);
|
||||||
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
|
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
|
||||||
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
|
final String voucherNo = generateBtVoucherNo(logId);
|
||||||
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
|
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
@@ -1032,28 +997,9 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
|
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
|
||||||
* under concurrent inserts on the same date.
|
* under concurrent inserts on the same date.
|
||||||
*/
|
*/
|
||||||
/**
|
private String generateBtVoucherNo(final long logId) {
|
||||||
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
|
||||||
* voucher so the two numbering schemes cannot drift apart.
|
+ String.format("%05d", logId);
|
||||||
*
|
|
||||||
* Numbered Journal rather than Debit: both sides of a bank transfer are member accounts, and
|
|
||||||
* the money never touches Cash in Hand or the vault either way (see the "no CashVault entry
|
|
||||||
* created anywhere" comments where these accounts are debited/credited directly). A DR number
|
|
||||||
* would have read as real cash paid out, double-counting the figure in My Vouchers on top of
|
|
||||||
* the day's actual Payment total.
|
|
||||||
*/
|
|
||||||
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
|
||||||
return voucherService.createVoucher(
|
|
||||||
"Bank Transfer", VoucherService.REF_BANK_TRANSFER_LOG,
|
|
||||||
logId, amount, currentUser, VoucherService.PREFIX_JOURNAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The TYPE column's label, in the same words the clerk's own TYPE dropdown uses for it. */
|
|
||||||
private static String voucherTypeLabelFor(final String requestType) {
|
|
||||||
if ("COLLECTION".equals(requestType)) return "Collection";
|
|
||||||
if ("BANK_TRANSFER".equals(requestType)) return "Bank Transfer";
|
|
||||||
if ("FUND_TRANSFER".equals(requestType)) return "Fund Transfer";
|
|
||||||
return "Disbursement";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1086,97 +1032,6 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the sides of an approved disbursement, collection or bank transfer on its voucher.
|
|
||||||
*
|
|
||||||
* The register covers three directions under one request type, and each puts the society's GL
|
|
||||||
* on a different side:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each
|
|
||||||
* account credited.</li>
|
|
||||||
* <li>COLLECTION — money into the target GL out of the listed accounts: each account debited,
|
|
||||||
* GL credited.</li>
|
|
||||||
* <li>BANK_TRANSFER — account to account with no GL involved at all; each entry already carries
|
|
||||||
* its own side, so they are used as given.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* The society's side is written as a single line for the total rather than one per counterparty,
|
|
||||||
* because that is how it posts and how the slip reads.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the approval: every transaction is posted by this point, and the
|
|
||||||
* money has moved. The problem is logged, and the voucher keeps its number without sides.
|
|
||||||
*/
|
|
||||||
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
|
|
||||||
final String requestType) {
|
|
||||||
|
|
||||||
final String voucherNo = (String) record.get("voucher_no");
|
|
||||||
if (voucherNo == null || voucherNo.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType);
|
|
||||||
final boolean isCollection = "COLLECTION".equals(requestType);
|
|
||||||
|
|
||||||
final List<VoucherLine> lines = new ArrayList<>();
|
|
||||||
BigDecimal counterpartyTotal = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
for (final JsonElement el : creditEntries) {
|
|
||||||
final JsonObject entry = el.getAsJsonObject();
|
|
||||||
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
|
|
||||||
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
|
|
||||||
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
|
|
||||||
? entry.get("accountName").getAsString()
|
|
||||||
: accountNo;
|
|
||||||
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
|
|
||||||
|
|
||||||
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
|
|
||||||
? entry.get("glAccountId").getAsLong()
|
|
||||||
: null;
|
|
||||||
final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
|
||||||
? entry.get("savingsAccountId").getAsLong()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// A bank transfer states each side on the entry itself; the other two directions
|
|
||||||
// put every counterparty on the side opposite the society's GL.
|
|
||||||
final boolean entryIsDebit = isBankTransfer
|
|
||||||
? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")
|
|
||||||
: isCollection;
|
|
||||||
|
|
||||||
final VoucherLine line;
|
|
||||||
if (isGlEntry) {
|
|
||||||
line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount)
|
|
||||||
: VoucherLine.creditGl(glId, accountName, accountNo, entryAmount);
|
|
||||||
} else {
|
|
||||||
line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount)
|
|
||||||
: VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount);
|
|
||||||
}
|
|
||||||
lines.add(line);
|
|
||||||
|
|
||||||
if (!isBankTransfer) {
|
|
||||||
counterpartyTotal = counterpartyTotal.add(entryAmount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The society's own side. A bank transfer has none — the money never leaves the members
|
|
||||||
// between them — so its entries already balance on their own.
|
|
||||||
if (!isBankTransfer) {
|
|
||||||
final String glName = (String) record.get("gl_account_name");
|
|
||||||
final String glNo = (String) record.get("gl_account_no");
|
|
||||||
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
|
|
||||||
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debits first, so the voucher prints the way a slip is read.
|
|
||||||
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
|
||||||
|
|
||||||
voucherService.addLines(voucherNo, lines);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||||
@@ -1287,13 +1142,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// This disbursement/collection request already raised its own voucher at submission and
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
// attaches every leg to it in addDisbursementVoucherLines() after approval succeeds — without
|
|
||||||
// withOwnVoucher() the plain savings withdrawal command below raises a second, orphaned
|
|
||||||
// voucher of its own for the same money (see VoucherService.withOwnVoucher's javadoc), which
|
|
||||||
// is what was double-counting a Collection's amount in both Received and Payments on My Vouchers.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,8 +1216,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// The disbursement register raises its own voucher; suppress the journal command's own.
|
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||||
@@ -1805,10 +1653,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// The disbursement register raises its own voucher, naming every account involved; the
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
// journal command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
return result != null ? result.getTransactionId() : null;
|
return result != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1144
File diff suppressed because it is too large
Load Diff
+19
-302
@@ -41,15 +41,12 @@ import java.sql.Date;
|
|||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -57,12 +54,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -82,13 +74,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClerkShareWithdrawalRequestApiResource {
|
public class ClerkShareWithdrawalRequestApiResource {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ClerkShareWithdrawalRequestApiResource.class);
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final VoucherService voucherService;
|
|
||||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||||
@@ -102,25 +91,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
||||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
||||||
|
|
||||||
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
|
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
|
||||||
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
|
|
||||||
// pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
|
|
||||||
// instead of silently posting its settlement with none at all.
|
|
||||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
|
|
||||||
|
|
||||||
// ── Sundry pass-through for the redemption settlement ───────────────────
|
|
||||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
|
||||||
private static final int CREDIT = 1;
|
|
||||||
private static final int DEBIT = 2;
|
|
||||||
|
|
||||||
// acc_product_mapping.financial_account_type for share products.
|
|
||||||
private static final int FA_SHARES_REFERENCE = 1;
|
|
||||||
private static final int FA_SHARES_SUSPENSE = 2;
|
|
||||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
|
||||||
|
|
||||||
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
|
|
||||||
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
|
||||||
private static final int FA_SAVINGS_CONTROL = 2;
|
|
||||||
|
|
||||||
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
||||||
|
|
||||||
@@ -216,9 +187,8 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||||
@QueryParam("search") final String search) {
|
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
||||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||||
@@ -236,11 +206,11 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
where.append(" AND status = ?");
|
where.append(" AND status = ?");
|
||||||
params.add(status);
|
params.add(status);
|
||||||
}
|
}
|
||||||
return queryList(where.toString(), params, fromDate, toDate, null, offset, limit);
|
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||||
final String search, final int offset, final int limit) {
|
final int offset, final int limit) {
|
||||||
final StringBuilder where = new StringBuilder(baseWhere);
|
final StringBuilder where = new StringBuilder(baseWhere);
|
||||||
final List<Object> params = new ArrayList<>(baseParams);
|
final List<Object> params = new ArrayList<>(baseParams);
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
@@ -251,13 +221,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
where.append(" AND DATE(submitted_at) <= ?");
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
params.add(Date.valueOf(toDate));
|
params.add(Date.valueOf(toDate));
|
||||||
}
|
}
|
||||||
if (search != null && !search.isBlank()) {
|
|
||||||
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)");
|
|
||||||
final String like = "%" + search.trim() + "%";
|
|
||||||
params.add(like);
|
|
||||||
params.add(like);
|
|
||||||
params.add(like);
|
|
||||||
}
|
|
||||||
|
|
||||||
final long total = Optional
|
final long total = Optional
|
||||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||||
@@ -357,7 +320,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||||
|
|
||||||
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
||||||
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied,
|
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
|
||||||
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
||||||
FROM m_share_account WHERE id = ?
|
FROM m_share_account WHERE id = ?
|
||||||
""", shareAccountId).stream().findFirst().orElse(null);
|
""", shareAccountId).stream().findFirst().orElse(null);
|
||||||
@@ -373,14 +336,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
: -1;
|
: -1;
|
||||||
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
||||||
|
|
||||||
// Active only means approved and activated -- not paid for. Real share capital is only posted
|
|
||||||
// once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and
|
|
||||||
// approve() below hard-blocks on this same flag, so a request failing this check can never
|
|
||||||
// actually be approved regardless of what a checker decides here.
|
|
||||||
final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied"));
|
|
||||||
checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied,
|
|
||||||
collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet"));
|
|
||||||
|
|
||||||
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
||||||
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
||||||
: null;
|
: null;
|
||||||
@@ -630,20 +585,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Object meetingDateObj = record.get("meeting_date");
|
final Object meetingDateObj = record.get("meeting_date");
|
||||||
final String resolutionNumber = (String) record.get("resolution_number");
|
final String resolutionNumber = (String) record.get("resolution_number");
|
||||||
|
|
||||||
// Account creation, approval and activation post no share-capital GL of their own -- a share
|
|
||||||
// account can be Active with "approved" shares that nobody has actually paid for yet, and only
|
|
||||||
// gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see
|
|
||||||
// ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was
|
|
||||||
// never issued, so this is a hard block here, not just an advisory line in validate()'s
|
|
||||||
// checklist -- a checker approving off that checklist alone has no other backstop.
|
|
||||||
final Boolean collectionApplied = jdbcTemplate
|
|
||||||
.queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream()
|
|
||||||
.findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null);
|
|
||||||
if (!Boolean.TRUE.equals(collectionApplied)) {
|
|
||||||
throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been "
|
|
||||||
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
final JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("locale", "en");
|
payload.addProperty("locale", "en");
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
@@ -662,20 +603,11 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||||
|
|
||||||
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
|
|
||||||
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
|
|
||||||
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
|
|
||||||
// "give cash back over the counter" assumption breaks that the moment the settlement below
|
|
||||||
// lands on a different GL than this leg did.
|
|
||||||
if (shareTransactionId != null) {
|
|
||||||
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
|
|
||||||
currentUser.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||||
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
|
// to the member's linked savings account. Two Fineract journal entries get posted — native
|
||||||
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
|
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
|
||||||
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
|
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
|
||||||
|
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
|
||||||
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
||||||
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
||||||
? ((Number) storedSettlementPaymentTypeId).longValue()
|
? ((Number) storedSettlementPaymentTypeId).longValue()
|
||||||
@@ -709,17 +641,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
// shares left for the native close command to force-redeem without oversight.
|
// shares left for the native close command to force-redeem without oversight.
|
||||||
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||||
|
|
||||||
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
|
|
||||||
// redemption settled into a member's savings account left nothing on the voucher register to
|
|
||||||
// read it back from. The GL entries were always written — it is the slip that was missing.
|
|
||||||
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
|
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
res.addProperty("resourceId", id);
|
res.addProperty("resourceId", id);
|
||||||
res.addProperty("status", "APPROVED");
|
res.addProperty("status", "APPROVED");
|
||||||
if (voucherNo != null) {
|
|
||||||
res.addProperty("voucherNo", voucherNo);
|
|
||||||
}
|
|
||||||
if (shareTxnId != null) {
|
if (shareTxnId != null) {
|
||||||
res.addProperty("shareTransactionId", shareTxnId);
|
res.addProperty("shareTransactionId", shareTxnId);
|
||||||
}
|
}
|
||||||
@@ -758,62 +682,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The voucher for an approved redemption: Dr Share Sundry, Cr the member's savings.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Mirrors the GL pair {@link #creditLinkedSavingsAccount} posts, so the slip and the ledger say
|
|
||||||
* the same thing. A redemption with no linked savings account settles nothing yet and raises no
|
|
||||||
* voucher — there is no second leg to name until somebody settles it by hand.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A failure here is logged and swallowed, never rethrown: the redemption, its GL entries and the
|
|
||||||
* savings credit have all already succeeded in this transaction, and losing the slip is worth
|
|
||||||
* less than rolling back a member's money.
|
|
||||||
*/
|
|
||||||
private String raiseShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
|
|
||||||
final Settlement settlement, final AppUser currentUser) {
|
|
||||||
|
|
||||||
if (!voucherService.shouldRaiseVoucher() || settlement == null
|
|
||||||
|| settlement.amount() == null || settlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
|
||||||
shareAccountId);
|
|
||||||
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
|
|
||||||
|
|
||||||
final Long savingsAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
|
|
||||||
.findFirst().orElse(null);
|
|
||||||
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
|
|
||||||
|
|
||||||
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
|
|
||||||
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
|
|
||||||
final String savingsAccountNo = savingsAccountId == null ? null
|
|
||||||
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
|
|
||||||
final List<VoucherLine> lines = new ArrayList<>();
|
|
||||||
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
|
|
||||||
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
|
|
||||||
|
|
||||||
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
|
|
||||||
// savings account, which is the receipt series — the share account is the source, not a
|
|
||||||
// counter payment.
|
|
||||||
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT,
|
|
||||||
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
|
|
||||||
if (settlement.savingsTransactionId() != null) {
|
|
||||||
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
|
|
||||||
settlement.savingsTransactionId());
|
|
||||||
}
|
|
||||||
return voucherNo;
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -821,17 +689,14 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
||||||
* the unit price ourselves — the transaction was already priced by Fineract's own
|
* the unit price ourselves — the transaction was already priced by Fineract's own
|
||||||
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
||||||
* today) and already nets out any redemption charge.
|
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
|
||||||
*
|
* command so the transaction is fully audited and posts its own GL entry the normal way — the
|
||||||
* <p>
|
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
|
||||||
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
|
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
|
||||||
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
|
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
|
||||||
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
|
* on that bridge account. Falls back to the legacy hardcoded
|
||||||
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
|
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
|
||||||
* to be left to the payment type's own mapping in the hope it would land on the same account
|
* column existed.
|
||||||
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
|
|
||||||
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
|
|
||||||
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
|
|
||||||
*/
|
*/
|
||||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||||
@@ -870,35 +735,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
final Long savingsTransactionId = result.getResourceId();
|
return new Settlement(settlementAmount, result.getResourceId());
|
||||||
|
|
||||||
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
|
|
||||||
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
|
|
||||||
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
|
|
||||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
|
|
||||||
|
|
||||||
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
|
||||||
final Long productId = toLong(acct.get("product_id"));
|
|
||||||
final Long officeId = toLong(acct.get("office_id"));
|
|
||||||
final String currencyCode = (String) acct.get("currency_code");
|
|
||||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
|
||||||
if (glSavingsControl == null) {
|
|
||||||
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
|
|
||||||
final long userId = context.authenticatedUser().getId();
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final String txnId = "SHRDMSTL-" + System.nanoTime();
|
|
||||||
final String accountNo = (String) acct.get("account_no");
|
|
||||||
|
|
||||||
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share redemption settled to savings | " + accountNo);
|
|
||||||
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share redemption settlement credited to savings | " + accountNo);
|
|
||||||
|
|
||||||
return new Settlement(settlementAmount, savingsTransactionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
||||||
@@ -969,126 +806,6 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sundry pass-through helpers ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
|
|
||||||
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
|
|
||||||
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
|
|
||||||
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
|
|
||||||
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
|
|
||||||
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
|
|
||||||
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
|
|
||||||
* the same way every other share-capital movement in this codebase does.
|
|
||||||
*/
|
|
||||||
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
|
|
||||||
final long userId) {
|
|
||||||
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
|
||||||
final Long productId = toLong(acct.get("product_id"));
|
|
||||||
final Long officeId = toLong(acct.get("office_id"));
|
|
||||||
final String currencyCode = (String) acct.get("currency_code");
|
|
||||||
final String accountNo = (String) acct.get("account_no");
|
|
||||||
|
|
||||||
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
|
|
||||||
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
|
|
||||||
final Map<String, Object> reversedLeg = jdbcTemplate
|
|
||||||
.queryForList(
|
|
||||||
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
|
|
||||||
+ " AND type_enum = ? AND reversed = false",
|
|
||||||
shareTransactionId, glReference, CREDIT)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (reversedLeg == null) {
|
|
||||||
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
|
|
||||||
// unexpected accounting configuration), so there is no leg here to reroute.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
|
|
||||||
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
|
|
||||||
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
|
|
||||||
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
|
|
||||||
// instead of the two reading as the one balanced entry they actually are.
|
|
||||||
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
|
|
||||||
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
|
|
||||||
+ " AND type_enum = ? AND reversed = false",
|
|
||||||
shareTransactionId, glReference, CREDIT);
|
|
||||||
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share redemption held in Sundry pending settlement | " + accountNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
|
|
||||||
return jdbcTemplate.queryForMap("""
|
|
||||||
SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id
|
|
||||||
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id
|
|
||||||
WHERE sa.id = ?
|
|
||||||
""", shareAccountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
|
|
||||||
final Long glId = resolveGl(productId, financialAccountType);
|
|
||||||
if (glId == null) {
|
|
||||||
throw apiError(409, label + " GL is not mapped for share product " + productId + ".");
|
|
||||||
}
|
|
||||||
return glId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long resolveGl(final Long productId, final int financialAccountType) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT gl_account_id FROM acc_product_mapping "
|
|
||||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
|
||||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
|
||||||
} catch (final IncorrectResultSizeDataAccessException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long savingsControlGlOf(final Long savingsAccountId) {
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT apm.gl_account_id FROM m_savings_account sa
|
|
||||||
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
|
||||||
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
|
|
||||||
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
|
|
||||||
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
|
||||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
|
||||||
final 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, entity_id, "
|
|
||||||
+ " 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, shareAccountId,
|
|
||||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
|
||||||
*
|
|
||||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back
|
|
||||||
* as 00:04 the next day. The instant has to be resolved in the tenant's own zone first, which is the zone
|
|
||||||
* {@code now} was taken in.
|
|
||||||
*/
|
|
||||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
|
||||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long toLong(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
||||||
JsonArray log = new JsonArray();
|
JsonArray log = new JsonArray();
|
||||||
if (existingJson != null && !existingJson.isBlank()) {
|
if (existingJson != null && !existingJson.isBlank()) {
|
||||||
|
|||||||
+11
-88
@@ -54,12 +54,7 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -74,12 +69,7 @@ import com.google.common.base.Splitter;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClerkWithdrawalRequestApiResource {
|
public class ClerkWithdrawalRequestApiResource {
|
||||||
|
|
||||||
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
|
||||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final KycRestrictionGuard kycRestrictionGuard;
|
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
@@ -124,7 +114,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||||
}
|
}
|
||||||
validateTokenUniqueToday(tokNo, null);
|
validateTokenUniqueToday(tokNo, null);
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
|
|
||||||
@@ -162,8 +151,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate,
|
@QueryParam("fromDate") final String fromDate,
|
||||||
@QueryParam("toDate") final String toDate,
|
@QueryParam("toDate") final String toDate) {
|
||||||
@QueryParam("search") final String search) {
|
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||||
@@ -175,14 +163,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
where.append(" AND DATE(submitted_at) <= ?");
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
baseParams.add(Date.valueOf(toDate));
|
baseParams.add(Date.valueOf(toDate));
|
||||||
}
|
}
|
||||||
if (search != null && !search.isBlank()) {
|
|
||||||
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)");
|
|
||||||
final String like = "%" + search.trim() + "%";
|
|
||||||
baseParams.add(like);
|
|
||||||
baseParams.add(like);
|
|
||||||
baseParams.add(like);
|
|
||||||
baseParams.add(like);
|
|
||||||
}
|
|
||||||
|
|
||||||
final long total = Optional.ofNullable(
|
final long total = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
||||||
@@ -457,11 +437,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
|
|
||||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||||
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
|
||||||
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
|
||||||
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
|
||||||
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
|
||||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||||
final Object ptId = record.get("payment_type_id");
|
final Object ptId = record.get("payment_type_id");
|
||||||
final Object txnDateObj = record.get("txn_date");
|
final Object txnDateObj = record.get("txn_date");
|
||||||
@@ -482,10 +457,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
|
||||||
// debited and the till that pays it out is credited.
|
|
||||||
addWithdrawalVoucherLines(record, savingsAccountId, amount);
|
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -623,11 +594,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
|
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The voucher was raised in the clerk's name at submission, before any cashier was involved --
|
|
||||||
// reattribute it to whoever is actually handing over the cash now, so it shows up in their own
|
|
||||||
// voucher register rather than only in the clerk's.
|
|
||||||
voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser);
|
|
||||||
|
|
||||||
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
saveDisbursementDenomination(denominationData, record, currentUser);
|
saveDisbursementDenomination(denominationData, record, currentUser);
|
||||||
@@ -697,7 +663,6 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String accountNo = (String) record.get("account_no");
|
final String accountNo = (String) record.get("account_no");
|
||||||
final Object clientIdObj = record.get("client_id");
|
final Object clientIdObj = record.get("client_id");
|
||||||
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
||||||
final String paymentTypeName = (String) record.get("payment_type_name");
|
|
||||||
|
|
||||||
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
||||||
final int count;
|
final int count;
|
||||||
@@ -721,42 +686,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
||||||
""",
|
""",
|
||||||
txnId, savingsAccountId, accountNo, clientId, denomId,
|
txnId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashier.getId(), cashier.getUsername(), paymentTypeName
|
cashier.getId(), cashier.getUsername()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the two sides of an approved withdrawal on its voucher.
|
|
||||||
*
|
|
||||||
* Money leaves the member and is paid out over the counter, so the member's account is debited
|
|
||||||
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
|
|
||||||
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the approval: the withdrawal is already posted and the member
|
|
||||||
* already paid. The problem is logged, and the voucher keeps its number without sides.
|
|
||||||
*/
|
|
||||||
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
|
||||||
final String voucherNo = (String) record.get("voucher_no");
|
|
||||||
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
voucherService.addLines(voucherNo,
|
|
||||||
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
|
|
||||||
(String) record.get("account_no"), amount),
|
|
||||||
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
@@ -797,13 +736,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The withdrawal register raised its own voucher at submission and attaches the member and
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// till sides to it on approval, so the savings command must not raise a second one over the
|
|
||||||
// same money -- one withdrawal was appearing twice on the cashier's payment report, once
|
|
||||||
// under the register's number and once under the savings transaction's, doubling the day's
|
|
||||||
// cash paid out.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,11 +764,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Suppressed for the same reason as the savings path above. Current accounts raise no
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// voucher of their own today, so this changes nothing now; it is here so that the day one
|
|
||||||
// is added, this register does not silently start double-counting again.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,9 +798,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
||||||
* this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} --
|
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
|
||||||
* all three are redeemed at the very same Transaction Entry token field, so none of the pools
|
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
|
||||||
* may ever collide. {@code excludeId} is set during resubmit to skip the record being edited.
|
* {@code excludeId} is set during resubmit to skip the record being edited.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
final StringBuilder sql = new StringBuilder(
|
final StringBuilder sql = new StringBuilder(
|
||||||
@@ -886,19 +815,13 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final long ownCount = Optional.ofNullable(
|
final long ownCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
final long dividendCount = Optional.ofNullable(
|
final long otherCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(
|
jdbcTemplate.queryForObject(
|
||||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
Long.class, tokNo)
|
Long.class, tokNo)
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
final long closureCount = Optional.ofNullable(
|
if (ownCount + otherCount > 0) {
|
||||||
jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM m_clerk_closure_request "
|
|
||||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
|
||||||
Long.class, tokNo)
|
|
||||||
).orElse(0L);
|
|
||||||
if (ownCount + dividendCount + closureCount > 0) {
|
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
}
|
}
|
||||||
|
|||||||
-404
@@ -1,404 +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.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.HeaderParam;
|
|
||||||
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.Context;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|
||||||
import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
|
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single client's active deposit accounts (Savings/Fixed Deposit/Recurring Deposit/Current -- Share
|
|
||||||
* accounts are out of scope for this endpoint, see below), looked up by client number OR mobile number
|
|
||||||
* (the path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no})
|
|
||||||
* rather than the internal client id -- these are what a counter clerk has on the slip, or what a
|
|
||||||
* third-party caller was given, respectively, so no separate client-search step is needed before this
|
|
||||||
* call. Built so the whole thing, including the client lookup itself, resolves in a small handful of
|
|
||||||
* index-scan-only queries.
|
|
||||||
*
|
|
||||||
* Deliberately narrow on purpose:
|
|
||||||
* <ul>
|
|
||||||
* <li>Only ACTIVE accounts (status_enum 300) are returned -- a closed, pending, or rejected account is
|
|
||||||
* not part of "what does this client currently hold", which is the question this endpoint answers.</li>
|
|
||||||
* <li>No balance/currentValue/onHoldFunds figures at all, for any account type -- this is an account
|
|
||||||
* listing, not a balance enquiry; a caller that needs the money figure is expected to hit the
|
|
||||||
* account-specific balance endpoint for it (kept as a smaller, separate, cacheable concern rather than
|
|
||||||
* mixed into every listing response).</li>
|
|
||||||
* <li>Share accounts are excluded entirely -- they carry no balance in the deposit sense (approved
|
|
||||||
* shares x unit price is a valuation, not a balance) and don't belong in a "deposit accounts" listing.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* Branch details (name, code, address, pincode, IFSC, MICR -- see {@code m_office_profile}, migrations
|
|
||||||
* 0511-0513/0538) and bank details (name, SWIFT/BIC, registered address, contact -- see
|
|
||||||
* {@code m_institution_profile}) are included on every response: a caller resolving "which branch/bank
|
|
||||||
* is this client's account actually at" (for a passbook header, a cheque book request, a KYC document)
|
|
||||||
* would otherwise need two more round trips, to {@code /v1/branchprofile/{officeId}} and
|
|
||||||
* {@code /v1/institutionprofile}.
|
|
||||||
*
|
|
||||||
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
|
|
||||||
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
|
|
||||||
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
|
|
||||||
* caller doesn't need. Fewer joins and fewer columns is what actually makes this fast, not a different
|
|
||||||
* query engine.
|
|
||||||
*
|
|
||||||
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
|
|
||||||
* they do every other endpoint; a third-party integration that should never see a Fineract username or
|
|
||||||
* password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted
|
|
||||||
* via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in
|
|
||||||
* {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the
|
|
||||||
* IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the
|
|
||||||
* handler itself is what actually authenticates the caller.
|
|
||||||
*/
|
|
||||||
@Path("/v1/clientaccounts")
|
|
||||||
@Component
|
|
||||||
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of a client's ACTIVE Savings/Fixed Deposit/Recurring Deposit/Current accounts (no balances, no Share accounts) plus their branch details, by client number or mobile number.")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ClientAllAccountsApiResource {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final ApiClientCredentialService apiClientCredentialService;
|
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
|
||||||
|
|
||||||
// Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local
|
|
||||||
// rather than depending on that filter bean: it's only conditionally registered
|
|
||||||
// (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether
|
|
||||||
// that unrelated feature toggle is on.
|
|
||||||
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
|
||||||
|
|
||||||
// ACTIVE-only status filter: 300 is SavingsAccountStatusType.ACTIVE, and m_current_account reuses
|
|
||||||
// the same status_enum numbering (see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the
|
|
||||||
// same convention elsewhere in this codebase). One UNION ALL, one round trip: SB/FD/RD
|
|
||||||
// (m_savings_account, split by deposit_type_enum) and CD (m_current_account, its own table --
|
|
||||||
// current accounts were never folded into m_savings_account here). Every leg filters on
|
|
||||||
// client_id, which is indexed on both tables (fksa00000000000001, idx_ca_client_id) -- an
|
|
||||||
// index-only lookup per leg, not a scan.
|
|
||||||
private static final String ACCOUNTS_SQL = """
|
|
||||||
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
|
|
||||||
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
|
|
||||||
p.name AS product_name, p.short_name AS short_product_name,
|
|
||||||
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
|
|
||||||
FROM m_savings_account sa
|
|
||||||
JOIN m_savings_product p ON p.id = sa.product_id
|
|
||||||
WHERE sa.client_id = ? AND sa.status_enum = 300
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
|
|
||||||
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
|
|
||||||
ca.submittedon_date, ca.activatedon_date
|
|
||||||
FROM m_current_account ca
|
|
||||||
JOIN m_current_product cp ON cp.id = ca.product_id
|
|
||||||
WHERE ca.client_id = ? AND ca.status_enum = 300
|
|
||||||
""";
|
|
||||||
|
|
||||||
// Branch details for the client's own office -- name, code, address, pincode, IFSC, MICR -- so a
|
|
||||||
// caller doesn't need a second round trip to /v1/branchprofile/{officeId} just to print a passbook
|
|
||||||
// header or route a cheque book request. LEFT JOIN: an office with no m_office_profile row yet
|
|
||||||
// (see BranchProfileApiResource) is a valid, ordinary state, not an error -- the profile fields
|
|
||||||
// just come back null.
|
|
||||||
private static final String BRANCH_SQL = """
|
|
||||||
SELECT o.id AS office_id, o.name AS office_name, p.branch_code, p.branch_type, p.address,
|
|
||||||
p.pincode, p.ifsc_code, p.micr_code, p.contact_phone, p.contact_email
|
|
||||||
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
|
|
||||||
WHERE o.id = ?
|
|
||||||
""";
|
|
||||||
|
|
||||||
// The bank itself (m_institution_profile, see InstitutionProfileApiResource) -- a singleton row,
|
|
||||||
// id=1, one per tenant. Only the fields a caller printing a passbook/statement header or routing a
|
|
||||||
// payment actually needs: identity, SWIFT/BIC (the institution-level counterpart to a branch's
|
|
||||||
// IFSC/MICR), registered address, and contact. Deliberately leaves out the authorised-representative
|
|
||||||
// block and terms-and-conditions text -- regulatory-filing detail, not account-lookup detail -- and
|
|
||||||
// the created/updated audit columns, which are an internal concern of the Bank Profile screen, not
|
|
||||||
// this endpoint's caller.
|
|
||||||
private static final String BANK_SQL = """
|
|
||||||
SELECT bank_name, legal_name, short_name, bank_code, swift_bic,
|
|
||||||
address_line1, address_line2, city, district, state, pincode,
|
|
||||||
contact_phone, contact_email, customer_care_number, website
|
|
||||||
FROM m_institution_profile WHERE id = 1
|
|
||||||
""";
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{identifier}")
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
public String retrieveByIdentifier(@PathParam("identifier") final String identifier,
|
|
||||||
@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
|
||||||
// Dual-mode: a header present means a third-party caller, checked against m_api_client and
|
|
||||||
// never touching Fineract's own login. No header means an ordinary logged-in user -- exactly
|
|
||||||
// the same authenticatedUser() gate every other resource in this codebase uses. The two are
|
|
||||||
// mutually exclusive on purpose: a request carrying an API key is never also allowed to fall
|
|
||||||
// back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a
|
|
||||||
// legitimate session's cookies/headers.
|
|
||||||
if (StringUtils.isNotBlank(apiKey)) {
|
|
||||||
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
|
||||||
} else {
|
|
||||||
context.authenticatedUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
|
||||||
final Map<String, Object> client = resolveClient(identifier, tenant);
|
|
||||||
final Long clientId = ((Number) client.get("id")).longValue();
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId);
|
|
||||||
|
|
||||||
final Map<String, Integer> countByType = new LinkedHashMap<>();
|
|
||||||
for (final Map<String, Object> row : rows) {
|
|
||||||
final String accountType = (String) row.get("accountType");
|
|
||||||
countByType.merge(accountType, 1, Integer::sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = ((Number) client.get("office_id")).longValue();
|
|
||||||
final Map<String, Object> branch = resolveBranch(officeId);
|
|
||||||
final Map<String, Object> bank = resolveBank();
|
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
|
||||||
// Which bank this response came from -- not decorative. One API key now authenticates
|
|
||||||
// against every tenant (see ApiClientCredentialService), so the ONLY thing that decides
|
|
||||||
// whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it
|
|
||||||
// (plus the bank's display name) back in the body means a caller debugging "why did I get
|
|
||||||
// client X's data instead of client Y's" can tell from the response alone, without having to
|
|
||||||
// go back and re-check what header value they actually sent.
|
|
||||||
res.addProperty("tenantId", tenant.getTenantIdentifier());
|
|
||||||
res.addProperty("tenantName", tenant.getName());
|
|
||||||
res.addProperty("searchedIdentifier", identifier);
|
|
||||||
res.addProperty("clientId", clientId);
|
|
||||||
res.addProperty("matchedBy", (String) client.get("matched_by"));
|
|
||||||
res.addProperty("clientNo", (String) client.get("account_no"));
|
|
||||||
res.addProperty("clientName", (String) client.get("display_name"));
|
|
||||||
res.addProperty("mobileNo", (String) client.get("mobile_no"));
|
|
||||||
res.addProperty("officeId", officeId);
|
|
||||||
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
|
|
||||||
res.add("branch", gson.toJsonTree(branch));
|
|
||||||
res.add("bank", gson.toJsonTree(bank));
|
|
||||||
res.addProperty("totalAccounts", rows.size());
|
|
||||||
res.add("accountsByType", gson.toJsonTree(countByType));
|
|
||||||
res.add("accounts", gson.toJsonTree(rows));
|
|
||||||
return gson.toJson(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An office with no {@code m_office_profile} row yet (see
|
|
||||||
* {@code org.apache.fineract.portfolio.orgsetup.api.BranchProfileApiResource}) is a valid, ordinary
|
|
||||||
* state -- every field beyond {@code officeId}/{@code officeName} just comes back null rather than
|
|
||||||
* this call failing or being skipped.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> resolveBranch(final Long officeId) {
|
|
||||||
final Map<String, Object> row = jdbcTemplate.queryForMap(BRANCH_SQL, officeId);
|
|
||||||
final Map<String, Object> branch = new LinkedHashMap<>();
|
|
||||||
branch.put("officeId", officeId);
|
|
||||||
branch.put("officeName", row.get("office_name"));
|
|
||||||
branch.put("branchCode", row.get("branch_code"));
|
|
||||||
branch.put("branchType", row.get("branch_type"));
|
|
||||||
branch.put("address", row.get("address"));
|
|
||||||
branch.put("pincode", row.get("pincode"));
|
|
||||||
branch.put("ifscCode", row.get("ifsc_code"));
|
|
||||||
branch.put("micrCode", row.get("micr_code"));
|
|
||||||
branch.put("contactPhone", row.get("contact_phone"));
|
|
||||||
branch.put("contactEmail", row.get("contact_email"));
|
|
||||||
return branch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bank running this tenant is a singleton row (id=1) that only gets created the first time
|
|
||||||
* someone opens the Bank Profile screen (see {@code InstitutionProfileApiResource#ensureRowExists}
|
|
||||||
* -- this endpoint never creates it itself, it only reads). A tenant where nobody has ever opened
|
|
||||||
* that screen yet is a valid, ordinary state, not an error -- {@code bank} just comes back {@code
|
|
||||||
* null} in the response rather than this call failing.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> resolveBank() {
|
|
||||||
final Map<String, Object> row;
|
|
||||||
try {
|
|
||||||
row = jdbcTemplate.queryForMap(BANK_SQL);
|
|
||||||
} catch (final EmptyResultDataAccessException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final Map<String, Object> bank = new LinkedHashMap<>();
|
|
||||||
bank.put("bankName", row.get("bank_name"));
|
|
||||||
bank.put("legalName", row.get("legal_name"));
|
|
||||||
bank.put("shortName", row.get("short_name"));
|
|
||||||
bank.put("bankCode", row.get("bank_code"));
|
|
||||||
bank.put("swiftBic", row.get("swift_bic"));
|
|
||||||
bank.put("addressLine1", row.get("address_line1"));
|
|
||||||
bank.put("addressLine2", row.get("address_line2"));
|
|
||||||
bank.put("city", row.get("city"));
|
|
||||||
bank.put("district", row.get("district"));
|
|
||||||
bank.put("state", row.get("state"));
|
|
||||||
bank.put("pincode", row.get("pincode"));
|
|
||||||
bank.put("contactPhone", row.get("contact_phone"));
|
|
||||||
bank.put("contactEmail", row.get("contact_email"));
|
|
||||||
bank.put("customerCareNumber", row.get("customer_care_number"));
|
|
||||||
bank.put("website", row.get("website"));
|
|
||||||
return bank;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
|
|
||||||
* the worst case, one in the common case, both on unique indexed columns
|
|
||||||
* ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}),
|
|
||||||
* so neither costs more than the single index-scan lookup this whole endpoint is built around.
|
|
||||||
* Client number is tried first because it's the identifier a teller normally has on the slip in
|
|
||||||
* front of them; falling through to mobile number covers the third-party case where that's the
|
|
||||||
* only thing the caller was given. A value that happens to collide across both columns for two
|
|
||||||
* different clients is not handled specially -- account numbers and mobile numbers use disjoint
|
|
||||||
* formats in this system, so this cannot occur in practice.
|
|
||||||
*
|
|
||||||
* {@code tenant} is only for the not-found message: with one API key now valid across every bank
|
|
||||||
* (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right
|
|
||||||
* identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is
|
|
||||||
* a genuinely unknown client -- naming the bank actually searched turns that from a guessing game
|
|
||||||
* into something the caller can act on immediately.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> resolveClient(final String identifier, final FineractPlatformTenant tenant) {
|
|
||||||
final List<Map<String, Object>> byAccountNo = jdbcTemplate.queryForList("""
|
|
||||||
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
|
|
||||||
FROM m_client WHERE account_no = ?
|
|
||||||
""", identifier);
|
|
||||||
if (!byAccountNo.isEmpty()) {
|
|
||||||
final Map<String, Object> client = new LinkedHashMap<>(byAccountNo.get(0));
|
|
||||||
client.put("matched_by", "CLIENT_NO");
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> byMobileNo = jdbcTemplate.queryForList("""
|
|
||||||
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
|
|
||||||
FROM m_client WHERE mobile_no = ?
|
|
||||||
""", identifier);
|
|
||||||
if (!byMobileNo.isEmpty()) {
|
|
||||||
final Map<String, Object> client = new LinkedHashMap<>(byMobileNo.get(0));
|
|
||||||
client.put("matched_by", "MOBILE_NO");
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw apiError(404, "No client found with client number or mobile number '" + identifier + "' in tenant '"
|
|
||||||
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
|
|
||||||
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the error response body itself rather than throwing a bare {@code jakarta.ws.rs.*}
|
|
||||||
* exception: {@code NotFoundException(String)} only sets the Java exception's own message (for
|
|
||||||
* logs), it never puts that text on the wire -- no exception mapper in this app converts a plain
|
|
||||||
* NotFoundException's message into the response body, so the caller always got
|
|
||||||
* {@code {timestamp,status,error,path}} with nothing else, no matter how descriptive the message
|
|
||||||
* passed to the constructor was.
|
|
||||||
*
|
|
||||||
* One message field, not the {@code message}/{@code defaultUserMessage}/{@code errors[0]
|
|
||||||
* .defaultUserMessage} triple {@code ClerkClosureRequestApiResource#apiError} uses: that shape
|
|
||||||
* exists there to line up with Fineract's own multi-field validation errors, where {@code errors}
|
|
||||||
* really does carry one entry per invalid parameter. This endpoint never raises more than one
|
|
||||||
* error at a time, so the same sentence repeated three times said nothing extra, only made the
|
|
||||||
* body longer.
|
|
||||||
*/
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
|
||||||
final JsonObject body = new JsonObject();
|
|
||||||
body.addProperty("httpStatusCode", httpStatus);
|
|
||||||
body.addProperty("message", message);
|
|
||||||
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveClientIp(final HttpServletRequest request) {
|
|
||||||
for (final String header : IP_HEADER_CANDIDATES) {
|
|
||||||
final String value = request.getHeader(header);
|
|
||||||
if (StringUtils.isNotBlank(value)) {
|
|
||||||
// X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is
|
|
||||||
// the original caller, which is what an allowlist entry is written against.
|
|
||||||
return value.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return request.getRemoteAddr();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps one UNION leg row into a flat, display-ready shape -- category label and human status only.
|
|
||||||
* No balance figure of any kind: this is an account listing, not a balance enquiry (see the class
|
|
||||||
* Javadoc).
|
|
||||||
*/
|
|
||||||
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
|
||||||
final int depositType = rs.getInt("deposit_type");
|
|
||||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
|
|
||||||
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("accountType", accountTypeFor(depositType));
|
|
||||||
row.put("accountId", rs.getLong("account_id"));
|
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
|
||||||
row.put("externalId", rs.getString("external_id"));
|
|
||||||
row.put("productId", rs.getLong("product_id"));
|
|
||||||
row.put("productName", rs.getString("product_name"));
|
|
||||||
row.put("shortProductName", rs.getString("short_product_name"));
|
|
||||||
row.put("currencyCode", rs.getString("currency_code"));
|
|
||||||
// .toString(), not the raw LocalDate: this Gson instance has no java.time adapter
|
|
||||||
// registered, so it falls back to reflecting LocalDate's private fields -- which the JVM
|
|
||||||
// module system blocks outright (java.base does not open java.time to reflection from an
|
|
||||||
// unnamed module), turning every response into a 500. A plain ISO-8601 string needs no
|
|
||||||
// adapter and is what every other date field in this API already returns.
|
|
||||||
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
|
|
||||||
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
|
|
||||||
|
|
||||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
|
||||||
row.put("status", status);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String accountTypeFor(final int depositType) {
|
|
||||||
return switch (depositType) {
|
|
||||||
case 100 -> "Savings Account";
|
|
||||||
case 200 -> "Fixed Deposit";
|
|
||||||
case 300 -> "Recurring Deposit";
|
|
||||||
case 400 -> "Current Account";
|
|
||||||
default -> "Other";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toIsoDateString(final LocalDate date) {
|
|
||||||
return date == null ? null : date.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user