cash-only registers, day book sections and DD amendments
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Cashier reports now reconcile against the drawer: - Received/Payment exclude what cannot be cash rather than admitting only proven cash, so registers that pay out without recording a denomination breakdown are no longer silently dropped - Closing report scoped to the cashier, with opening/closing balance, reversed counter transactions excluded, and cash DD receipts counted - Day book split into Cashier and Clerk sections, filed by whose cash moved rather than who raised the request Fixed one deposit or withdrawal raising two vouchers: the cashier deposit and clerk withdrawal registers did not suppress the inner savings command's voucher, doubling the day's cash on both reports. Sponsor DD: - Amendment request flow (beneficiary, payable-at branch and city) under maker-checker; face value and funding stay unamendable - Cash DD denominations were collected but never saved, and the amount check demanded a figure notes could not add to; both fixed and the breakdown is now required Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
-- Removes the duplicate vouchers left behind by the double-count bug.
|
||||||
|
--
|
||||||
|
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
|
||||||
|
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
|
||||||
|
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
|
||||||
|
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
|
||||||
|
-- and Payment reports counted both, doubling the day's cash.
|
||||||
|
--
|
||||||
|
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
|
||||||
|
--
|
||||||
|
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
|
||||||
|
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
|
||||||
|
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
|
||||||
|
-- that link and orphan the request row.
|
||||||
|
--
|
||||||
|
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
|
||||||
|
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
|
||||||
|
-- genuinely separate transactions of the same value on the same day are never confused.
|
||||||
|
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
|
||||||
|
SELECT sav.voucher_no
|
||||||
|
FROM m_voucher sav
|
||||||
|
JOIN m_voucher reg
|
||||||
|
ON reg.savings_txn_id = sav.reference_id
|
||||||
|
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
|
||||||
|
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
|
||||||
|
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
|
||||||
|
|
||||||
|
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
|
||||||
|
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
|
||||||
|
-- rather than removed silently.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
blocked INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO blocked
|
||||||
|
FROM (SELECT voucher_no FROM m_voucher_verification
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no FROM m_voucher_attachment
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
|
||||||
|
IF blocked > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
|
||||||
|
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
stamped INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
IF stamped > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
|
||||||
|
|
||||||
|
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
|||||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
(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,
|
||||||
@@ -168,6 +160,5 @@ VALUES
|
|||||||
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
('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,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS 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;
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_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);
|
||||||
|
|
||||||
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS 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;
|
||||||
|
|
||||||
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_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);
|
||||||
|
|||||||
+19
-6
@@ -181,10 +181,14 @@ 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
|
||||||
|
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
.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);
|
||||||
@@ -210,20 +214,29 @@ 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
|
||||||
|
// only, so they are validated when supplied rather than demanded from every savings product.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
||||||
|
element)) {
|
||||||
final Long overdraftAccount = this.fromApiJsonHelper
|
final Long overdraftAccount = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||||
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
||||||
element);
|
final Long writtenOff = this.fromApiJsonHelper
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||||
.integerGreaterThanZero();
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
||||||
|
.notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -41,6 +41,8 @@ public enum JobName {
|
|||||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
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"), //
|
||||||
|
|||||||
+39
@@ -484,6 +484,14 @@ 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
|
||||||
@@ -677,6 +685,34 @@ 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.getLocalDateOfTenant().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();
|
||||||
@@ -798,6 +834,9 @@ 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,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
@@ -48,6 +49,18 @@ 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
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
|
||||||
|
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
|
||||||
|
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
|
||||||
|
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
|
||||||
|
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
|
||||||
|
* a manual decision with its own notice.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
|
||||||
|
* notice" without fixing a number — so they are declared here once and read from here everywhere.
|
||||||
|
*/
|
||||||
|
public enum KycExpiryStage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside the grace window. Nothing is restricted; the customer is being chased.
|
||||||
|
*/
|
||||||
|
GRACE(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
|
||||||
|
* the customer into the branch without putting their incoming money at risk.
|
||||||
|
*/
|
||||||
|
PARTIAL_FREEZE(90),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both directions restricted, barring the credits that must always be let through.
|
||||||
|
*/
|
||||||
|
INOPERATIVE(180);
|
||||||
|
|
||||||
|
/** Days past the expiry date at which this stage begins. */
|
||||||
|
private final int fromDaysOverdue;
|
||||||
|
|
||||||
|
KycExpiryStage(final int fromDaysOverdue) {
|
||||||
|
this.fromDaysOverdue = fromDaysOverdue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getFromDaysOverdue() {
|
||||||
|
return fromDaysOverdue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage an account sits at, given how far past its re-KYC due date it is.
|
||||||
|
*
|
||||||
|
* @param daysOverdue
|
||||||
|
* days since the expiry date; zero or negative means the KYC has not lapsed
|
||||||
|
* @return the stage, or {@code null} where the KYC is still current and nothing applies
|
||||||
|
*/
|
||||||
|
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
|
||||||
|
if (daysOverdue < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
|
||||||
|
return INOPERATIVE;
|
||||||
|
}
|
||||||
|
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
|
||||||
|
return PARTIAL_FREEZE;
|
||||||
|
}
|
||||||
|
return GRACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGrace() {
|
||||||
|
return this == GRACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPartialFreeze() {
|
||||||
|
return this == PARTIAL_FREEZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInoperative() {
|
||||||
|
return this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether debits should be stopped at this stage. */
|
||||||
|
public boolean blocksDebits() {
|
||||||
|
return this == PARTIAL_FREEZE || this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether ordinary credits should be stopped at this stage. */
|
||||||
|
public boolean blocksCredits() {
|
||||||
|
return this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
-1
@@ -258,7 +258,6 @@ 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";
|
||||||
|
|
||||||
|
|||||||
-4
@@ -77,9 +77,6 @@ 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;
|
||||||
|
|
||||||
@@ -120,7 +117,6 @@ 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,9 +136,6 @@ 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,7 +378,6 @@ 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,9 +75,6 @@ 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;
|
||||||
|
|
||||||
@@ -125,8 +122,6 @@ 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; }
|
||||||
@@ -147,7 +142,6 @@ 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,7 +117,6 @@ 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);
|
||||||
|
|||||||
+4
@@ -64,9 +64,13 @@ 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) {
|
||||||
|
|||||||
+12
-2
@@ -664,7 +664,13 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
// The deposit register raised its own voucher at submission and attaches the till and
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +746,11 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
// Suppressed for the same reason as the savings path above. Current accounts raise no
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+190
-12
@@ -13,6 +13,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
@@ -24,6 +25,7 @@ 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,6 +36,24 @@ public class CashReportApiResource {
|
|||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excludes reversed counter transactions from a cash figure.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD
|
||||||
|
* register keys its rows by its own request id, not a savings transaction -- is kept rather than
|
||||||
|
* silently dropped.
|
||||||
|
*/
|
||||||
|
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) ";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
* Day book — every voucher raised on one date, in one list, newest first.
|
||||||
*
|
*
|
||||||
@@ -72,7 +92,11 @@ public class CashReportApiResource {
|
|||||||
|
|
||||||
// Cashier deposits — money in, so the credit side.
|
// Cashier deposits — money in, so the credit side.
|
||||||
rows.addAll(jdbcTemplate.query("""
|
rows.addAll(jdbcTemplate.query("""
|
||||||
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
|
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.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.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
|
||||||
r.submitted_at, r.actioned_at,
|
r.submitted_at, r.actioned_at,
|
||||||
@@ -84,13 +108,31 @@ public class CashReportApiResource {
|
|||||||
|
|
||||||
// Clerk withdrawals — money out, so the debit side.
|
// Clerk withdrawals — money out, so the debit side.
|
||||||
rows.addAll(jdbcTemplate.query("""
|
rows.addAll(jdbcTemplate.query("""
|
||||||
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
|
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,
|
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
||||||
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
|
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,
|
r.submitted_at, r.actioned_at,
|
||||||
""" + verifyCols + """
|
""" + verifyCols + """
|
||||||
FROM m_clerk_withdrawal_request r
|
FROM m_clerk_withdrawal_request r
|
||||||
""" + verifyJoin + """
|
""" + 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)
|
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
||||||
""", this::mapDayBookRow, txnDate));
|
""", this::mapDayBookRow, txnDate));
|
||||||
|
|
||||||
@@ -125,7 +167,7 @@ public class CashReportApiResource {
|
|||||||
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
|
// 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.
|
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
|
||||||
rows.addAll(jdbcTemplate.query("""
|
rows.addAll(jdbcTemplate.query("""
|
||||||
SELECT r.voucher_no,
|
SELECT r.voucher_no, 'CLERK' AS section,
|
||||||
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
|
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.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.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
|
||||||
@@ -137,6 +179,37 @@ public class CashReportApiResource {
|
|||||||
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
|
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
|
||||||
""", this::mapDayBookRow, txnDate));
|
""", 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));
|
||||||
|
|
||||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
||||||
// ordered as one result set, and the row count for a single day is small.
|
// ordered as one result set, and the row count for a single day is small.
|
||||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
||||||
@@ -195,6 +268,7 @@ public class CashReportApiResource {
|
|||||||
result.put("date", txnDate);
|
result.put("date", txnDate);
|
||||||
result.put("rows", filtered);
|
result.put("rows", filtered);
|
||||||
result.put("count", filtered.size());
|
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("creditTotal", creditTotal);
|
||||||
result.put("debitTotal", debitTotal);
|
result.put("debitTotal", debitTotal);
|
||||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
result.put("netTotal", creditTotal.subtract(debitTotal));
|
||||||
@@ -204,6 +278,10 @@ public class CashReportApiResource {
|
|||||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
row.put("voucherNo", rs.getString("voucher_no"));
|
||||||
|
// 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"));
|
||||||
row.put("side", rs.getString("side"));
|
row.put("side", rs.getString("side"));
|
||||||
row.put("kind", rs.getString("kind"));
|
row.put("kind", rs.getString("kind"));
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
row.put("accountNo", rs.getString("account_no"));
|
||||||
@@ -241,12 +319,21 @@ public class CashReportApiResource {
|
|||||||
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
|
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
|
||||||
return row;
|
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
|
||||||
|
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||||
|
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
|
||||||
|
|
||||||
|
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
|
||||||
|
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
|
||||||
|
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||||
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||||
|
|
||||||
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
||||||
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
||||||
@@ -255,10 +342,16 @@ public class CashReportApiResource {
|
|||||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
||||||
+ "FROM m_savings_txn_denomination std "
|
+ "FROM m_savings_txn_denomination std "
|
||||||
+ "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_date = CAST(? AS DATE) "
|
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
||||||
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
||||||
|
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
args.add(txnDate);
|
||||||
|
if (scopedCashierId != null) {
|
||||||
|
args.add(scopedCashierId);
|
||||||
|
}
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
long depositCount = rs.getLong("deposit_count");
|
long depositCount = rs.getLong("deposit_count");
|
||||||
long withdrawalCount = rs.getLong("withdrawal_count");
|
long withdrawalCount = rs.getLong("withdrawal_count");
|
||||||
@@ -275,7 +368,7 @@ public class CashReportApiResource {
|
|||||||
row.put("netCount", depositCount - withdrawalCount);
|
row.put("netCount", depositCount - withdrawalCount);
|
||||||
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
||||||
return row;
|
return row;
|
||||||
}, txnDate);
|
}, args.toArray());
|
||||||
|
|
||||||
BigDecimal depositTotal = BigDecimal.ZERO;
|
BigDecimal depositTotal = BigDecimal.ZERO;
|
||||||
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
||||||
@@ -285,6 +378,7 @@ 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, "
|
||||||
@@ -294,10 +388,16 @@ 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) "
|
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
|
||||||
+ "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");
|
||||||
@@ -314,18 +414,96 @@ 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;
|
||||||
}, txnDate);
|
}, exArgs.toArray());
|
||||||
|
|
||||||
|
// 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);
|
||||||
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
|
// Reported separately as well, so a screen can show where the difference comes from rather
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
@@ -672,7 +850,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 = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||||
+ "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
|
||||||
@@ -698,7 +876,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 = ?) "
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||||
+ "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)
|
||||||
|
|||||||
+5
@@ -22,6 +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.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -31,6 +32,8 @@ 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;
|
||||||
@@ -41,6 +44,7 @@ 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 {
|
||||||
@@ -52,6 +56,7 @@ 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;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
+12
-2
@@ -788,7 +788,13 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
// The withdrawal register raised its own voucher at submission and attaches the member and
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +822,11 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
// Suppressed for the same reason as the savings path above. Current accounts raise no
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
@@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource {
|
|||||||
}
|
}
|
||||||
return this.toApiJsonSerializer.serialize(exceptions);
|
return this.toApiJsonSerializer.serialize(exceptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers whose periodic re-KYC has already fallen due.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Sits under the exceptions path because it is the same worklist to the branch — a customer the
|
||||||
|
* bank has to reach — and the outreach is recorded against the same contact log.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("lapsed")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve lapsed KYC records", description = "Returns clients whose periodic re-KYC due date has passed, oldest first.")
|
||||||
|
public String retrieveLapsedKyc() {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
final List<ClientKycData> lapsed = this.readPlatformService.retrieveLapsedKyc();
|
||||||
|
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
|
||||||
|
return this.toApiJsonSerializer.serialize(lapsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions;
|
||||||
|
|
||||||
|
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 ApplyKycExpiryRestrictionsConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step applyKycExpiryRestrictionsStep(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
|
||||||
|
return new StepBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
|
||||||
|
.tasklet(applyKycExpiryRestrictionsTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job applyKycExpiryRestrictionsJob(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
|
||||||
|
return new JobBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
|
||||||
|
.start(applyKycExpiryRestrictionsStep(applyKycExpiryRestrictionsTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.jobs.applykycexpiryrestrictions;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nightly pass over customers whose periodic re-KYC has lapsed, applying the restriction each one
|
||||||
|
* has reached and releasing those whose KYC has since been renewed.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class ApplyKycExpiryRestrictionsTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final KycExpiryRestrictionService kycExpiryRestrictionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
kycExpiryRestrictionService.applyRestrictions();
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -35,4 +35,14 @@ public interface ClientKycReadPlatformService {
|
|||||||
java.util.List<ClientKycData> retrieveActiveKycExceptions();
|
java.util.List<ClientKycData> retrieveActiveKycExceptions();
|
||||||
|
|
||||||
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
|
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KYC records whose periodic re-KYC due date has already passed, oldest first.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These are the customers the branch has to chase and, once the grace period runs out, the
|
||||||
|
* accounts the restriction ladder acts on. A record whose renewal is still ahead is not
|
||||||
|
* included: nothing is owed on it yet.
|
||||||
|
*/
|
||||||
|
java.util.List<ClientKycData> retrieveLapsedKyc();
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -25,6 +25,7 @@ import org.apache.fineract.portfolio.client.data.ClientKycData;
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
|
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycType;
|
import org.apache.fineract.portfolio.client.domain.ClientKycType;
|
||||||
import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
|
import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
|
||||||
@@ -159,4 +160,12 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
|||||||
.map(this::toData)
|
.map(this::toData)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.List<ClientKycData> retrieveLapsedKyc() {
|
||||||
|
return this.kycRepository
|
||||||
|
.findLapsedAsOn(DateUtils.getBusinessLocalDate(), ClientKycStatus.REJECTED.getValue()).stream()
|
||||||
|
.map(this::toData)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies and lifts the account restrictions that follow a lapsed periodic re-KYC.
|
||||||
|
*/
|
||||||
|
public interface KycExpiryRestrictionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks every lapsed KYC, applies the stage each one has reached, and releases customers whose
|
||||||
|
* KYC is current again.
|
||||||
|
*
|
||||||
|
* @return what the run did, for the job log
|
||||||
|
*/
|
||||||
|
KycExpiryRunSummary applyRestrictions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one run of the ladder did.
|
||||||
|
*
|
||||||
|
* @param lapsed
|
||||||
|
* KYC records past their due date
|
||||||
|
* @param inGrace
|
||||||
|
* of those, the ones still inside the grace window with nothing restricted
|
||||||
|
* @param debitBlocked
|
||||||
|
* customers whose debits are stopped
|
||||||
|
* @param fullyBlocked
|
||||||
|
* customers whose accounts are inoperative
|
||||||
|
* @param released
|
||||||
|
* accounts freed because the KYC was renewed
|
||||||
|
*/
|
||||||
|
record KycExpiryRunSummary(int lapsed, int inGrace, int debitBlocked, int fullyBlocked, int released) {}
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService.KycExpiryRunSummary;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the restriction ladder that follows a lapsed periodic re-KYC, and lifts it once the KYC
|
||||||
|
* is renewed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The ladder is described on {@link KycExpiryStage}. In short: nothing happens for the grace period,
|
||||||
|
* then debits stop, then the account goes inoperative. Closure is never reached automatically.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Restrictions are derived from the expiry date on every run rather than remembered, so a KYC that
|
||||||
|
* is renewed simply stops being lapsed and its accounts are released on the next pass. That also
|
||||||
|
* makes the job safe to re-run: applying a stage that is already applied changes nothing.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KycExpiryRestrictionServiceImpl implements KycExpiryRestrictionService {
|
||||||
|
|
||||||
|
private final ClientKycRepository clientKycRepository;
|
||||||
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public KycExpiryRunSummary applyRestrictions() {
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final List<ClientKyc> lapsed = clientKycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue());
|
||||||
|
|
||||||
|
int debitBlocked = 0;
|
||||||
|
int fullyBlocked = 0;
|
||||||
|
int inGrace = 0;
|
||||||
|
|
||||||
|
for (final ClientKyc kyc : lapsed) {
|
||||||
|
final long daysOverdue = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
|
||||||
|
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
|
||||||
|
if (stage == null || stage.isGrace()) {
|
||||||
|
// Inside the grace window nothing is restricted; the customer is only being chased.
|
||||||
|
inGrace++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long clientId = kyc.getClient().getId();
|
||||||
|
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
|
||||||
|
boolean changedAny = false;
|
||||||
|
|
||||||
|
for (final SavingsAccount account : accounts) {
|
||||||
|
final boolean changed = stage.isInoperative() ? account.applyKycExpiryFullBlock() : account.applyKycExpiryDebitBlock();
|
||||||
|
if (changed) {
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
changedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.isInoperative()) {
|
||||||
|
fullyBlocked++;
|
||||||
|
} else {
|
||||||
|
debitBlocked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedAny) {
|
||||||
|
log.info("Client {} re-KYC lapsed {} days ago — {} applied to their savings accounts", clientId, daysOverdue, stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final int released = releaseRenewed(today);
|
||||||
|
final KycExpiryRunSummary summary = new KycExpiryRunSummary(lapsed.size(), inGrace, debitBlocked, fullyBlocked, released);
|
||||||
|
log.info("KYC expiry restrictions applied: {}", summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts restrictions from customers whose KYC is current again.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Renewing a KYC pushes its expiry date into the future, so a renewed customer simply drops out
|
||||||
|
* of the lapsed set. Nothing records that this job was the one that blocked the account, so only
|
||||||
|
* the two states the ladder itself sets are cleared — see
|
||||||
|
* {@link SavingsAccount#releaseKycExpiryBlock()} — and a manual block is left untouched.
|
||||||
|
*/
|
||||||
|
private int releaseRenewed(final LocalDate today) {
|
||||||
|
final List<ClientKyc> current = clientKycRepository.findAllByStatus(ClientKycStatus.VERIFIED.getValue());
|
||||||
|
int released = 0;
|
||||||
|
|
||||||
|
for (final ClientKyc kyc : current) {
|
||||||
|
if (kyc.getExpiryDate() == null || !kyc.getExpiryDate().isAfter(today)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(kyc.getClient().getId())) {
|
||||||
|
if (account.releaseKycExpiryBlock()) {
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
released++;
|
||||||
|
log.info("Client {} re-KYC renewed to {} — restrictions lifted on savings account {}", kyc.getClient().getId(),
|
||||||
|
kyc.getExpiryDate(), account.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DeafRemittanceWritePlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records remittances of escheated balances to RBI's Depositor Education and Awareness Fund.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The transfer to RBI happens outside this system; these endpoints only write down that it was
|
||||||
|
* made, so the escheat report can distinguish money that reached the fund from money that has left
|
||||||
|
* the customer's account and gone nowhere.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsaccounts/deaf-remittance")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "DEAF Remittance", description = "Record remittance of escheated balances to RBI's DEAF.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeafRemittanceApiResource {
|
||||||
|
|
||||||
|
private final DeafRemittanceWritePlatformService writePlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record a DEAF remittance", description = "Marks the escheated balances of the given savings accounts "
|
||||||
|
+ "as remitted to RBI. Accounts that are not escheated, or already recorded, are skipped.")
|
||||||
|
public String recordRemittance(final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final JsonCommand command = JsonCommand.fromJsonElement(null, parsed, this.fromJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.writePlatformService.recordRemittance(command);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{savingsAccountId}/refund")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record a claim against a remitted balance", description = "Marks that the customer claimed the balance "
|
||||||
|
+ "back after it had gone to the fund, and the bank recovered it from RBI.")
|
||||||
|
public String markRefunded(@PathParam("savingsAccountId") final Long savingsAccountId, final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final JsonCommand command = JsonCommand.fromJsonElement(savingsAccountId, parsed, this.fromJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.writePlatformService.markRefunded(savingsAccountId, command);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of the dormancy ladder: which accounts the nightly job has marked, and how long
|
||||||
|
* each has been idle.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsaccounts/dormant")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Dormant Savings Accounts", description = "Accounts marked inactive, dormant or escheated by the dormancy job.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DormantAccountsApiResource {
|
||||||
|
|
||||||
|
private final DormantAccountReadPlatformService readPlatformService;
|
||||||
|
private final DefaultToApiJsonSerializer<DormantAccountData> toApiJsonSerializer;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve dormant accounts", description = "Accounts at any of the three dormancy marks, longest idle first. "
|
||||||
|
+ "Pass subStatus to narrow to one: 100 inactive, 200 dormant, 300 escheat.")
|
||||||
|
public String retrieveDormantAccounts(@QueryParam("subStatus") final Integer subStatus,
|
||||||
|
@QueryParam("officeName") final String officeName) {
|
||||||
|
final List<DormantAccountData> accounts = this.readPlatformService.retrieveDormantAccounts(subStatus, officeName);
|
||||||
|
return this.toApiJsonSerializer.serialize(accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
||||||
@@ -50,6 +51,7 @@ public class SavingsDailyInterestOrchestrator {
|
|||||||
/** Accrue for the day, then post for any product whose cycle ends today. */
|
/** Accrue for the day, then post for any product whose cycle ends today. */
|
||||||
public void runForDate(final LocalDate businessDate) {
|
public void runForDate(final LocalDate businessDate) {
|
||||||
accrualService.accrueForDate(businessDate);
|
accrualService.accrueForDate(businessDate);
|
||||||
|
enrolNewProducts();
|
||||||
|
|
||||||
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
||||||
SELECT s.savings_product_id, s.posting_period_enum
|
SELECT s.savings_product_id, s.posting_period_enum
|
||||||
@@ -88,6 +90,66 @@ public class SavingsDailyInterestOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrols the product behind one account, if it is not already on the timetable.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Called when an account is activated, so the first account opened on a brand-new product brings
|
||||||
|
* that product onto the timetable rather than the product waiting for the nightly sweep. The
|
||||||
|
* insert is guarded the same way as {@link #enrolNewProducts()}, so calling it for a product
|
||||||
|
* already enrolled does nothing.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void enrolProductOf(final Long savingsAccountId) {
|
||||||
|
try {
|
||||||
|
final int enrolled = jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_interest_posting_schedule
|
||||||
|
(savings_product_id, posting_period_enum, run_at_time, is_active)
|
||||||
|
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ?
|
||||||
|
AND sp.deposit_type_enum = 100
|
||||||
|
AND sp.interest_posting_period_enum IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
|
||||||
|
WHERE s.savings_product_id = sp.id)
|
||||||
|
""", savingsAccountId);
|
||||||
|
if (enrolled > 0) {
|
||||||
|
log.info("Savings interest: enrolled the product behind account {} into the daily-interest schedule", savingsAccountId);
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// Called from activation, which must not be rolled back because the timetable could not
|
||||||
|
// be written. The nightly sweep enrols the product anyway.
|
||||||
|
log.error("Savings interest: could not enrol the product behind account {}; the nightly run will do it",
|
||||||
|
savingsAccountId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nightly sweep: gives any savings product without a timetable one, on its own posting period.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Activation enrols a product as soon as its first account opens, so this is the safety net —
|
||||||
|
* it catches a product whose accounts were activated while this was not yet wired, or where the
|
||||||
|
* enrolment on activation failed. The period comes from the product's own
|
||||||
|
* {@code interest_posting_period_enum}, so a product set to post monthly posts monthly.
|
||||||
|
*/
|
||||||
|
private void enrolNewProducts() {
|
||||||
|
final int enrolled = jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_interest_posting_schedule
|
||||||
|
(savings_product_id, posting_period_enum, run_at_time, is_active)
|
||||||
|
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
|
||||||
|
FROM m_savings_product sp
|
||||||
|
WHERE sp.deposit_type_enum = 100
|
||||||
|
AND sp.interest_posting_period_enum IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
|
||||||
|
WHERE s.savings_product_id = sp.id)
|
||||||
|
""");
|
||||||
|
if (enrolled > 0) {
|
||||||
|
log.info("Savings interest: enrolled {} new savings product(s) into the daily-interest schedule", enrolled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
|
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
|
||||||
* itself recorded, so the schedule and the rows can never drift apart.
|
* itself recorded, so the schedule and the rows can never drift apart.
|
||||||
|
|||||||
+113
-29
@@ -118,6 +118,62 @@ public class SavingsDailyInterestService {
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the ledger for one newly activated account.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The nightly run would pick the account up on its own, but not until tomorrow, and until then
|
||||||
|
* the account is simply absent from the ledger — which reads as though it is not being accrued at
|
||||||
|
* all. Called from activation, it writes the account's first row there and then, so an account is
|
||||||
|
* on the ledger from the day it opens.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only savings accounts are accrued. Anything else — a term or recurring deposit — is left alone,
|
||||||
|
* and a failure is logged rather than raised: the activation itself has succeeded and must not be
|
||||||
|
* rolled back because the ledger could not be opened. The nightly run will pick it up.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Runs in the caller's transaction, deliberately. Activation sets the account active in that
|
||||||
|
* transaction and has not committed yet, so a REQUIRES_NEW transaction here would read the row as
|
||||||
|
* it was before — still approved, not yet active — find nothing to accrue, and return quietly
|
||||||
|
* having done nothing. Joining the caller sees the activation, and the status is not re-checked
|
||||||
|
* for the same reason: the value in the database is the pre-activation one until commit.
|
||||||
|
*
|
||||||
|
* @return true if a row was written
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean accrueOnActivation(final Long savingsAccountId, final LocalDate businessDate) {
|
||||||
|
try {
|
||||||
|
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ? AND sp.deposit_type_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ?
|
||||||
|
""", savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate);
|
||||||
|
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean written = accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth());
|
||||||
|
if (written) {
|
||||||
|
log.info("Savings daily interest: opened the ledger for newly activated account {}", savingsAccountId);
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Savings daily interest: could not open the ledger for account {} on activation; the nightly run will "
|
||||||
|
+ "pick it up", savingsAccountId, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
|
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
|
||||||
final int financialYearBeginningMonth) {
|
final int financialYearBeginningMonth) {
|
||||||
final Long accountId = ((Number) account.get("id")).longValue();
|
final Long accountId = ((Number) account.get("id")).longValue();
|
||||||
@@ -130,16 +186,19 @@ public class SavingsDailyInterestService {
|
|||||||
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
|
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
|
||||||
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
|
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
|
||||||
|
|
||||||
// The previous row is the only thing that carries the chain forward. Its absence means
|
// The account's row is the only thing that carries the chain forward: it holds the balance
|
||||||
// this is the account's first day; a gap would mean a day the job never ran, and rather
|
// the last accrued day closed on and the cycle's running total. Its absence means this is
|
||||||
// than silently re-anchoring we take the last row we do have and let the reconciliation
|
// the account's first day.
|
||||||
// report the break.
|
//
|
||||||
|
// The ledger keeps one row per account, so the row is read and then rewritten in place with
|
||||||
|
// today's figures. Only a row for a day before today can be carried forward — one already
|
||||||
|
// stamped with today or later means the day has been accrued and re-reading it as "previous"
|
||||||
|
// would compound the same day's interest onto itself.
|
||||||
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||||
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
||||||
cycle_start_date, cycle_end_date, is_posting_day
|
cycle_start_date, cycle_end_date, is_posting_day
|
||||||
FROM m_savings_daily_interest
|
FROM m_savings_daily_interest
|
||||||
WHERE savings_account_id = ? AND balance_date < ?
|
WHERE savings_account_id = ? AND balance_date < ?
|
||||||
ORDER BY balance_date DESC LIMIT 1
|
|
||||||
""", accountId, businessDate);
|
""", accountId, businessDate);
|
||||||
|
|
||||||
final BigDecimal openingBalance;
|
final BigDecimal openingBalance;
|
||||||
@@ -152,20 +211,30 @@ public class SavingsDailyInterestService {
|
|||||||
openingBalance = BigDecimal.ZERO;
|
openingBalance = BigDecimal.ZERO;
|
||||||
carriedInterest = BigDecimal.ZERO;
|
carriedInterest = BigDecimal.ZERO;
|
||||||
cycleNumber = 1;
|
cycleNumber = 1;
|
||||||
// Cycle 1 is a stub: it starts when the account was activated, not on the grid.
|
// The first row belongs to the cycle the day it records falls in, and starts at
|
||||||
cycleStart = activatedOn;
|
// activation only when that day is still inside the account's opening cycle. Anchoring
|
||||||
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth);
|
// on activation alone dates the row into a cycle that closed months ago: an account
|
||||||
|
// opened in June, first accrued in September, would be stamped with June's cycle end
|
||||||
|
// and sit there permanently past its own posting date.
|
||||||
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
|
||||||
} else {
|
} else {
|
||||||
final Map<String, Object> prev = previous.get(0);
|
final Map<String, Object> prev = previous.get(0);
|
||||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||||
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||||
if (prevWasPosting) {
|
// A cycle is finished either because it was paid out or because its end date has gone
|
||||||
// The cycle closed yesterday: the sum went to the member and starts again at zero.
|
// by. The second case happens whenever the ledger has a gap over a posting date - the
|
||||||
|
// run was missed, or the account was accrued again after a break - and treating it as
|
||||||
|
// unfinished would leave the row in a cycle that ended before the day it records.
|
||||||
|
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
|
||||||
carriedInterest = BigDecimal.ZERO;
|
carriedInterest = BigDecimal.ZERO;
|
||||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||||
cycleStart = prevCycleEnd.plusDays(1);
|
cycleStart = prevCycleEnd.plusDays(1);
|
||||||
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod,
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
} else {
|
} else {
|
||||||
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||||
@@ -232,26 +301,36 @@ public class SavingsDailyInterestService {
|
|||||||
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
|
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
|
||||||
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
|
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET
|
ON CONFLICT (savings_account_id) DO UPDATE SET
|
||||||
|
balance_date = EXCLUDED.balance_date,
|
||||||
opening_balance = EXCLUDED.opening_balance,
|
opening_balance = EXCLUDED.opening_balance,
|
||||||
-- A day whose interest has already been paid keeps the balance it was left
|
-- Taken as computed. The interest this ledger posts is counted as movement on
|
||||||
-- with. Re-accruing that day recomputes the closing balance from
|
-- the day it was paid (see dayMovement), so the recomputed closing already
|
||||||
-- transactions alone, which does not include this ledger's own posting, and
|
-- carries it and needs no adjustment here.
|
||||||
-- writing it back would erase the credit from the chain: the next day would
|
closing_balance = EXCLUDED.closing_balance,
|
||||||
-- open lower than the member's real balance and they would stop earning on
|
|
||||||
-- interest they have been given.
|
|
||||||
closing_balance = CASE
|
|
||||||
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
|
||||||
THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount
|
|
||||||
ELSE EXCLUDED.closing_balance END,
|
|
||||||
daily_interest = EXCLUDED.daily_interest,
|
daily_interest = EXCLUDED.daily_interest,
|
||||||
-- Likewise the total that was actually paid: once money has moved on the
|
-- Likewise the total that was actually paid: once money has moved on the
|
||||||
-- strength of this figure, recomputing it would leave the ledger disagreeing
|
-- strength of this figure, recomputing the same day would leave the ledger
|
||||||
-- with the member's passbook. A posted day is settled.
|
-- disagreeing with the member's passbook. A posted day is settled — but only
|
||||||
|
-- that day. The next day starts the new cycle's total from zero, which is what
|
||||||
|
-- the accrual has computed, so it is taken as given.
|
||||||
cycle_cumulative_interest = CASE
|
cycle_cumulative_interest = CASE
|
||||||
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
||||||
|
AND m_savings_daily_interest.balance_date = EXCLUDED.balance_date
|
||||||
THEN m_savings_daily_interest.cycle_cumulative_interest
|
THEN m_savings_daily_interest.cycle_cumulative_interest
|
||||||
ELSE EXCLUDED.cycle_cumulative_interest END,
|
ELSE EXCLUDED.cycle_cumulative_interest END,
|
||||||
|
-- Cleared when the row moves to a new day: posted_amount and the transaction
|
||||||
|
-- behind it belong to the day they were paid on, and carrying them onto the
|
||||||
|
-- next day would make an unposted cycle look settled.
|
||||||
|
posted_amount = CASE
|
||||||
|
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
|
||||||
|
THEN m_savings_daily_interest.posted_amount ELSE NULL END,
|
||||||
|
savings_transaction_id = CASE
|
||||||
|
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
|
||||||
|
THEN m_savings_daily_interest.savings_transaction_id ELSE NULL END,
|
||||||
|
posting_run_id = CASE
|
||||||
|
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
|
||||||
|
THEN m_savings_daily_interest.posting_run_id ELSE NULL END,
|
||||||
cycle_number = EXCLUDED.cycle_number,
|
cycle_number = EXCLUDED.cycle_number,
|
||||||
cycle_start_date = EXCLUDED.cycle_start_date,
|
cycle_start_date = EXCLUDED.cycle_start_date,
|
||||||
cycle_end_date = EXCLUDED.cycle_end_date,
|
cycle_end_date = EXCLUDED.cycle_end_date,
|
||||||
@@ -299,16 +378,21 @@ public class SavingsDailyInterestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The day's net movement, excluding interest postings.
|
* The day's net movement, interest postings included.
|
||||||
*
|
*
|
||||||
* Interest postings are left out on purpose. The posting is this ledger's own output, and
|
* Interest credited by this ledger counts as movement like any other credit. It has to: the
|
||||||
* folding it back in here would let it accrue interest on itself a second time. It is added to
|
* ledger holds one row per account, so a balance is only ever built from the row before it, and
|
||||||
* the closing balance by the posting run, on the posting day, once.
|
* a posting left out of the movement is a posting missing from every balance that follows. The
|
||||||
|
* member is paid, their passbook shows it, and the ledger would go on earning on the balance
|
||||||
|
* they had before - the interest silently stops compounding.
|
||||||
|
*
|
||||||
|
* It cannot double-count, because a posting only ever falls on one day and the row for that day
|
||||||
|
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
|
||||||
*/
|
*/
|
||||||
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
||||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(CASE
|
SELECT COALESCE(SUM(CASE
|
||||||
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount
|
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||||
ELSE 0 END), 0)
|
ELSE 0 END), 0)
|
||||||
FROM m_savings_account_transaction t
|
FROM m_savings_account_transaction t
|
||||||
|
|||||||
+92
-9
@@ -40,11 +40,24 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||||
* holds the figure that would say so.
|
* holds the figure that would say so.
|
||||||
*
|
*
|
||||||
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings
|
* This posts the product's total first, into the liability the product names as its Savings Interest
|
||||||
* Interest Posted Control, and only then pays it out to members. The whole product settles inside
|
* Payable, and only then pays it out to members:
|
||||||
* one transaction, and both control accounts have to read zero before it is allowed to commit. A
|
*
|
||||||
* failure rolls the product back entirely - nobody is half paid - and a run that never reached
|
* <pre>
|
||||||
* commit leaves a PENDING row naming the product and date for the next run to pick up.
|
* Dr Savings Interest Paid / Interest On Savings (expense, from the product)
|
||||||
|
* Cr Savings Interest Payable (liability, from the product)
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Payable (per member)
|
||||||
|
* Cr Savings Control (per member, from the product)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Every account named above is read from the product's own GL configuration; none is written into
|
||||||
|
* this class. A product that configures no payable falls back to the two seeded control accounts,
|
||||||
|
* which keeps products predating that mapping posting exactly as before.
|
||||||
|
*
|
||||||
|
* The whole product settles inside one transaction, and the payable has to read zero before it is
|
||||||
|
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
|
||||||
|
* that never reached commit leaves a PENDING row naming the product and date for the next run.
|
||||||
*
|
*
|
||||||
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
||||||
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
||||||
@@ -61,6 +74,15 @@ public class SavingsPooledInterestPostingService {
|
|||||||
private static final int MONEY_SCALE = 2;
|
private static final int MONEY_SCALE = 2;
|
||||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved names in the product's own GL mappings, which is where the two legs of the interest
|
||||||
|
* bill are configured. The names are the contract between this service and the product screen —
|
||||||
|
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account.
|
||||||
|
*/
|
||||||
|
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
|
||||||
|
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
|
||||||
|
|
||||||
|
/** Fallback control accounts, used only where the product leaves the mappings unset. */
|
||||||
private static final String GL_POSTING_CONTROL = "200304";
|
private static final String GL_POSTING_CONTROL = "200304";
|
||||||
private static final String GL_POSTED_CONTROL = "200305";
|
private static final String GL_POSTED_CONTROL = "200305";
|
||||||
|
|
||||||
@@ -142,20 +164,34 @@ public class SavingsPooledInterestPostingService {
|
|||||||
largest.get("savings_account_id"));
|
largest.get("savings_account_id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL);
|
// The expense the bill is recognised against, and the liability it sits in until it reaches
|
||||||
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL);
|
// the members. Both come from the product: "Savings Interest Paid" if the product names one,
|
||||||
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
|
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the
|
||||||
|
// liability, falling back to the control accounts only where a product configures neither.
|
||||||
|
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID),
|
||||||
|
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS));
|
||||||
|
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE);
|
||||||
|
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL));
|
||||||
|
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL);
|
||||||
|
|
||||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||||
final String currency = (String) due.get(0).get("currency_code");
|
final String currency = (String) due.get(0).get("currency_code");
|
||||||
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||||
|
|
||||||
// Stage 1: the product's whole interest bill, recognised and pooled.
|
// Stage 1: the product's whole interest bill, recognised and parked in the payable.
|
||||||
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
||||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
|
||||||
// Stage 2: the pool moves to the account it is paid out of.
|
// Stage 2: the pool moves to the account it is paid out of.
|
||||||
|
//
|
||||||
|
// Where the product names a single payable, both control legs resolve to it and this stage
|
||||||
|
// would debit and credit the same account for the same amount — a pair of entries that say
|
||||||
|
// nothing and clutter the ledger. Only a product still on the two distinct control accounts
|
||||||
|
// needs the transfer written.
|
||||||
|
if (!postingControlGl.equals(postedControlGl)) {
|
||||||
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
||||||
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
}
|
||||||
|
|
||||||
// Stage 3: out to the members, one at a time.
|
// Stage 3: out to the members, one at a time.
|
||||||
BigDecimal distributed = BigDecimal.ZERO;
|
BigDecimal distributed = BigDecimal.ZERO;
|
||||||
@@ -177,6 +213,9 @@ public class SavingsPooledInterestPostingService {
|
|||||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||||
accountId, transactionId);
|
accountId, transactionId);
|
||||||
|
|
||||||
|
// closing_balance is raised here because the interest transaction has just been written
|
||||||
|
// and the accrual for this day has already run: nothing else will fold it in before the
|
||||||
|
// next day reads this row as its opening.
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_savings_daily_interest
|
UPDATE m_savings_daily_interest
|
||||||
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||||
@@ -297,4 +336,48 @@ public class SavingsPooledInterestPostingService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", Long.class, productId, financialAccountType);
|
""", Long.class, productId, financialAccountType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account a product has mapped under one of the reserved names in its own GL mappings.
|
||||||
|
*
|
||||||
|
* <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 a payable configured under the wrong heading
|
||||||
|
* should still be honoured rather than silently ignored.
|
||||||
|
*
|
||||||
|
* @return the account id, or null where the product does not configure this name
|
||||||
|
*/
|
||||||
|
private Long namedGlAccount(final long productId, final String accountName) {
|
||||||
|
// 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 in 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, productId, accountName);
|
||||||
|
return found.isEmpty() ? null : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long firstNonNull(final Long preferred, final Long fallback) {
|
||||||
|
return preferred != null ? preferred : fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a savings product's interest terms down onto the accounts opened under it.
|
||||||
|
*
|
||||||
|
* Fineract copies rate, posting period, compounding period, calculation type and days-in-year onto
|
||||||
|
* each account when it is created, and the interest engine reads that copy from then on. Editing the
|
||||||
|
* product changes nothing for accounts that already exist: a product moved from 7% to 10% goes on
|
||||||
|
* paying 7% on every account opened before the edit, indefinitely and silently. Because the copies
|
||||||
|
* are what the engine reads, they are also what the member is actually paid.
|
||||||
|
*
|
||||||
|
* This bank's rule is that interest follows the product, so an edit has to reach the accounts. The
|
||||||
|
* cascade runs on the same transaction as the product update, which means an edit either lands
|
||||||
|
* everywhere or nowhere - no state where the product says one thing and its accounts another.
|
||||||
|
*
|
||||||
|
* Scope is deliberately narrow:
|
||||||
|
*
|
||||||
|
* - savings only (deposit_type_enum 100). Fixed and recurring deposits keep the rate they were
|
||||||
|
* contracted at; that is the nature of a term deposit and changing it retroactively would
|
||||||
|
* rewrite an agreement.
|
||||||
|
* - active and pending accounts only. A closed account's terms are history.
|
||||||
|
* - min_balance_for_interest_calculation is never touched: it exists only on the account, so
|
||||||
|
* there is nothing on the product to cascade from.
|
||||||
|
*
|
||||||
|
* Interest already posted is not disturbed. Only future accrual moves, which is what an interest
|
||||||
|
* rate change ordinarily means.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsProductTermsCascadeService {
|
||||||
|
|
||||||
|
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||||
|
|
||||||
|
/** m_savings_account.status_enum: 100 submitted/pending approval, 200 approved, 300 active. */
|
||||||
|
private static final String CASCADEABLE_STATUSES = "(100, 200, 300)";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the product's interest terms onto its savings accounts.
|
||||||
|
*
|
||||||
|
* @param productId
|
||||||
|
* the product that was edited
|
||||||
|
* @param changes
|
||||||
|
* what the update actually changed, from the command result; the cascade is skipped
|
||||||
|
* when none of the interest fields are among them
|
||||||
|
* @return how many accounts were updated
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int cascadeInterestTerms(final Long productId, final Map<String, Object> changes) {
|
||||||
|
if (productId == null || changes == null || changes.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!touchesInterestTerms(changes)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> product = jdbcTemplate.queryForList("""
|
||||||
|
SELECT nominal_annual_interest_rate, interest_compounding_period_enum,
|
||||||
|
interest_posting_period_enum, interest_calculation_type_enum,
|
||||||
|
interest_calculation_days_in_year_type_enum
|
||||||
|
FROM m_savings_product
|
||||||
|
WHERE id = ? AND deposit_type_enum = ?
|
||||||
|
""", productId, SAVINGS_DEPOSIT_TYPE);
|
||||||
|
if (product.isEmpty()) {
|
||||||
|
// Not a savings product - a fixed or recurring deposit, which is left alone.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only rows that actually differ are written, so the count reported is the number of
|
||||||
|
// accounts whose terms really moved rather than the size of the product's book.
|
||||||
|
final int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_account sa
|
||||||
|
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
|
||||||
|
interest_compounding_period_enum = sp.interest_compounding_period_enum,
|
||||||
|
interest_posting_period_enum = sp.interest_posting_period_enum,
|
||||||
|
interest_calculation_type_enum = sp.interest_calculation_type_enum,
|
||||||
|
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
|
||||||
|
FROM m_savings_product sp
|
||||||
|
WHERE sp.id = sa.product_id
|
||||||
|
AND sa.product_id = ?
|
||||||
|
AND sp.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
|
||||||
|
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
|
||||||
|
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
|
||||||
|
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
|
||||||
|
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
|
||||||
|
OR sa.interest_calculation_days_in_year_type_enum
|
||||||
|
<> sp.interest_calculation_days_in_year_type_enum)
|
||||||
|
""", productId, SAVINGS_DEPOSIT_TYPE);
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
log.info("Savings product {}: interest terms cascaded to {} account(s) — {}", productId, updated,
|
||||||
|
changes.keySet());
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the edit touched anything the accounts hold a copy of.
|
||||||
|
*
|
||||||
|
* The names are the API's own parameter names, which is what the update result reports.
|
||||||
|
*/
|
||||||
|
private boolean touchesInterestTerms(final Map<String, Object> changes) {
|
||||||
|
return changes.containsKey("nominalAnnualInterestRate")
|
||||||
|
|| changes.containsKey("interestCompoundingPeriodType")
|
||||||
|
|| changes.containsKey("interestPostingPeriodType")
|
||||||
|
|| changes.containsKey("interestCalculationType")
|
||||||
|
|| changes.containsKey("interestCalculationDaysInYearType");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings every savings account in line with its product, regardless of what changed.
|
||||||
|
*
|
||||||
|
* For the accounts that drifted before the cascade existed - in this tenant, accounts opened on
|
||||||
|
* a 7% daily copy of a product that now reads 10% quarterly.
|
||||||
|
*
|
||||||
|
* @return how many accounts were updated
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int realignAllAccounts() {
|
||||||
|
final int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_account sa
|
||||||
|
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
|
||||||
|
interest_compounding_period_enum = sp.interest_compounding_period_enum,
|
||||||
|
interest_posting_period_enum = sp.interest_posting_period_enum,
|
||||||
|
interest_calculation_type_enum = sp.interest_calculation_type_enum,
|
||||||
|
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
|
||||||
|
FROM m_savings_product sp
|
||||||
|
WHERE sp.id = sa.product_id
|
||||||
|
AND sp.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
|
||||||
|
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
|
||||||
|
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
|
||||||
|
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
|
||||||
|
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
|
||||||
|
OR sa.interest_calculation_days_in_year_type_enum
|
||||||
|
<> sp.interest_calculation_days_in_year_type_enum)
|
||||||
|
""", SAVINGS_DEPOSIT_TYPE);
|
||||||
|
log.info("Savings interest terms realigned to product on {} account(s)", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accounts whose terms differ from their product, for a report or a pre-flight check. */
|
||||||
|
public List<Map<String, Object>> findDrifted() {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id AS account_id, sa.account_no, sp.id AS product_id, sp.name AS product_name,
|
||||||
|
sa.nominal_annual_interest_rate AS account_rate,
|
||||||
|
sp.nominal_annual_interest_rate AS product_rate,
|
||||||
|
sa.interest_posting_period_enum AS account_posting,
|
||||||
|
sp.interest_posting_period_enum AS product_posting,
|
||||||
|
sa.interest_calculation_type_enum AS account_calculation,
|
||||||
|
sp.interest_calculation_type_enum AS product_calculation
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sp.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
|
||||||
|
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
|
||||||
|
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
|
||||||
|
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
|
||||||
|
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
|
||||||
|
OR sa.interest_calculation_days_in_year_type_enum
|
||||||
|
<> sp.interest_calculation_days_in_year_type_enum)
|
||||||
|
ORDER BY sp.id, sa.id
|
||||||
|
""", SAVINGS_DEPOSIT_TYPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -53,8 +54,35 @@ public class SavingsDailyInterestApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final SavingsDailyInterestService accrualService;
|
private final SavingsDailyInterestService accrualService;
|
||||||
private final SavingsDailyInterestOrchestrator orchestrator;
|
private final SavingsDailyInterestOrchestrator orchestrator;
|
||||||
|
private final SavingsProductTermsCascadeService termsCascadeService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts whose interest terms differ from the product they were opened under.
|
||||||
|
*
|
||||||
|
* A product edit cascades to its accounts now, but accounts opened before that was so can still
|
||||||
|
* be sitting on the terms they were created with — and those copies are what the engine pays on.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/drifted")
|
||||||
|
public String drifted() {
|
||||||
|
final List<Map<String, Object>> rows = termsCascadeService.findDrifted();
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("count", rows.size());
|
||||||
|
result.put("accounts", rows);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings every savings account's interest terms back in line with its product. */
|
||||||
|
@POST
|
||||||
|
@Path("/realign")
|
||||||
|
public String realign() {
|
||||||
|
final int updated = termsCascadeService.realignAllAccounts();
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("accountsRealigned", updated);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
/** The savings products carrying a ledger, with where each one is in its timetable. */
|
/** The savings products carrying a ledger, with where each one is in its timetable. */
|
||||||
@GET
|
@GET
|
||||||
@Path("/products")
|
@Path("/products")
|
||||||
@@ -117,6 +145,119 @@ public class SavingsDailyInterestApiResource {
|
|||||||
return gson.toJson(rows);
|
return gson.toJson(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per account: where each one stands right now.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The ledger behind this is day-by-day and has to stay that way — interest is worked out on the
|
||||||
|
* balance each day carried, and a reversed backdated entry means every day after it is
|
||||||
|
* recomputed. What a reader wants, though, is the current position without reading thirty rows
|
||||||
|
* to find it, which is what {@code v_savings_interest_summary} gives: the latest day's figures
|
||||||
|
* per account, with the cycle total and the lifetime posted total beside them.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Optionally narrowed to one product, one office, or one account.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/summary")
|
||||||
|
public String summary(@QueryParam("productId") final Long productId, @QueryParam("officeName") final String officeName,
|
||||||
|
@QueryParam("accountNo") final String accountNo) {
|
||||||
|
final List<Object> args = new java.util.ArrayList<>();
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT * FROM v_savings_interest_summary WHERE 1 = 1");
|
||||||
|
|
||||||
|
if (productId != null) {
|
||||||
|
sql.append(" AND savings_product_id = ?");
|
||||||
|
args.add(productId);
|
||||||
|
}
|
||||||
|
if (officeName != null && !officeName.isBlank()) {
|
||||||
|
sql.append(" AND office_name = ?");
|
||||||
|
args.add(officeName);
|
||||||
|
}
|
||||||
|
if (accountNo != null && !accountNo.isBlank()) {
|
||||||
|
sql.append(" AND account_no = ?");
|
||||||
|
args.add(accountNo);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY account_no");
|
||||||
|
|
||||||
|
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest actually paid to members, one row per posting.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read from the savings transactions rather than the daily ledger. The ledger holds one row per
|
||||||
|
* account carrying the current cycle, and clears the posted amount when the next cycle opens, so
|
||||||
|
* it cannot answer what was paid last quarter. The transaction is the payment itself and stays.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@code period} narrows to one posting rhythm, matching the product's own
|
||||||
|
* {@code interest_posting_period_enum}: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
|
||||||
|
* 7 annual. A report screen shows each rhythm on its own, because a monthly product's
|
||||||
|
* month-end posting and a quarterly product's quarter-end posting are different events and
|
||||||
|
* mixing them reads as one uneven series.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/postings")
|
||||||
|
public String postings(@QueryParam("period") final Integer period, @QueryParam("productId") final Long productId,
|
||||||
|
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
|
@QueryParam("officeName") final String officeName) {
|
||||||
|
final List<Object> args = new java.util.ArrayList<>();
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT t.id AS transaction_id,
|
||||||
|
t.savings_account_id AS savings_account_id,
|
||||||
|
sa.account_no AS account_no,
|
||||||
|
sa.client_id AS client_id,
|
||||||
|
c.display_name AS client_name,
|
||||||
|
o.name AS office_name,
|
||||||
|
sa.product_id AS savings_product_id,
|
||||||
|
sp.name AS product_name,
|
||||||
|
sp.interest_posting_period_enum AS posting_period_enum,
|
||||||
|
sp.nominal_annual_interest_rate AS interest_rate,
|
||||||
|
sa.currency_code AS currency_code,
|
||||||
|
-- Formatted here rather than left as a date: this resource serialises with a
|
||||||
|
-- plain Gson, which renders a java.sql.Date as an object the browser cannot
|
||||||
|
-- read back, and every posting showed as "Invalid Date".
|
||||||
|
TO_CHAR(t.transaction_date, 'YYYY-MM-DD') AS posting_date,
|
||||||
|
t.amount AS interest_amount,
|
||||||
|
t.running_balance_derived AS balance_after,
|
||||||
|
(t.running_balance_derived - t.amount) AS balance_before,
|
||||||
|
t.created_on_utc AS recorded_on
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
WHERE t.transaction_type_enum = 3
|
||||||
|
AND t.is_reversed = false
|
||||||
|
AND sp.deposit_type_enum = 100
|
||||||
|
""");
|
||||||
|
|
||||||
|
if (period != null) {
|
||||||
|
sql.append(" AND sp.interest_posting_period_enum = ? ");
|
||||||
|
args.add(period);
|
||||||
|
}
|
||||||
|
if (productId != null) {
|
||||||
|
sql.append(" AND sa.product_id = ? ");
|
||||||
|
args.add(productId);
|
||||||
|
}
|
||||||
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
|
sql.append(" AND t.transaction_date >= ? ");
|
||||||
|
args.add(LocalDate.parse(fromDate));
|
||||||
|
}
|
||||||
|
if (toDate != null && !toDate.isBlank()) {
|
||||||
|
sql.append(" AND t.transaction_date <= ? ");
|
||||||
|
args.add(LocalDate.parse(toDate));
|
||||||
|
}
|
||||||
|
if (officeName != null && !officeName.isBlank()) {
|
||||||
|
sql.append(" AND o.name = ? ");
|
||||||
|
args.add(officeName);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY t.transaction_date DESC, sa.account_no");
|
||||||
|
|
||||||
|
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
|
||||||
|
}
|
||||||
|
|
||||||
/** What each posting run pooled and paid, and whether it finished. */
|
/** What each posting run pooled and paid, and whether it finished. */
|
||||||
@GET
|
@GET
|
||||||
@Path("/runs")
|
@Path("/runs")
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||||
|
|
||||||
|
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 SavingsDailyInterestConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step savingsDailyInterestStep(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
|
||||||
|
return new StepBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
|
||||||
|
.tasklet(savingsDailyInterestTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job savingsDailyInterestJob(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
|
||||||
|
return new JobBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
|
||||||
|
.start(savingsDailyInterestStep(savingsDailyInterestTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the day's savings interest: accrue every account's interest for the business date, then post
|
||||||
|
* for the products whose cycle ends on it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Until this existed the orchestrator was only reachable through its API, so the ledger accrued for
|
||||||
|
* as long as somebody kept calling it and posted only when somebody remembered to. Interest a bank
|
||||||
|
* owes its depositors is not something to run by hand.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class SavingsDailyInterestTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final SavingsDailyInterestOrchestrator orchestrator;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
orchestrator.runForDate(DateUtils.getBusinessLocalDate());
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account that has gone quiet, as the dormancy reports read it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@code lastCustomerTransactionDate} is the heart of it: RBI measures the silence from the last
|
||||||
|
* transaction the <em>customer</em> made, so interest the bank posts and charges it recovers are
|
||||||
|
* excluded when this is worked out. Where an account has never been transacted on, the activation
|
||||||
|
* date stands in — the clock has to start somewhere, and that is the day the account began.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public class DormantAccountData {
|
||||||
|
|
||||||
|
private final Long accountId;
|
||||||
|
private final String accountNo;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientName;
|
||||||
|
private final String officeName;
|
||||||
|
private final String productName;
|
||||||
|
private final BigDecimal balance;
|
||||||
|
private final String mobileNo;
|
||||||
|
|
||||||
|
/** 100 inactive, 200 dormant, 300 escheat. */
|
||||||
|
private final Integer subStatus;
|
||||||
|
private final String subStatusCode;
|
||||||
|
|
||||||
|
private final LocalDate lastCustomerTransactionDate;
|
||||||
|
private final Long daysIdle;
|
||||||
|
|
||||||
|
/** The product's own periods, so the report can say when the next mark falls. */
|
||||||
|
private final Long daysToInactive;
|
||||||
|
private final Long daysToDormancy;
|
||||||
|
private final Long daysToEscheat;
|
||||||
|
|
||||||
|
/** Escheat only — the day the account was closed and the balance taken out. */
|
||||||
|
private final LocalDate closedOnDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escheat only — the day the balance was actually remitted to RBI's DEAF, or null if that has
|
||||||
|
* not been recorded. A null here on an escheated account means the money is out of the
|
||||||
|
* customer's account but not yet accounted for as having reached the fund.
|
||||||
|
*/
|
||||||
|
private final LocalDate deafRemittedOn;
|
||||||
|
|
||||||
|
/** RBI's acknowledgement reference for the remittance. */
|
||||||
|
private final String deafReference;
|
||||||
|
|
||||||
|
/** Set if the customer later claimed the balance and it was recovered from the fund. */
|
||||||
|
private final LocalDate deafRefundedOn;
|
||||||
|
}
|
||||||
-6
@@ -88,7 +88,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
|
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
|
||||||
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
|
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
|
||||||
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
|
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
|
||||||
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
|
|
||||||
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
|
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
|
||||||
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
|
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
|
||||||
FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING");
|
FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING");
|
||||||
@@ -155,8 +154,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
|
pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(),
|
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(),
|
||||||
pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
|
pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(),
|
|
||||||
pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
|
|
||||||
populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(),
|
populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(),
|
||||||
pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
|
pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(),
|
populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(),
|
||||||
@@ -418,7 +415,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
putIfPresent(fc, "neftFacility", p.getNeftFacility());
|
putIfPresent(fc, "neftFacility", p.getNeftFacility());
|
||||||
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
|
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
|
||||||
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
||||||
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
|
|
||||||
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
||||||
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
||||||
putIfPresent(fc, "internetBanking", p.getInternetBanking());
|
putIfPresent(fc, "internetBanking", p.getInternetBanking());
|
||||||
@@ -449,7 +445,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility());
|
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility());
|
||||||
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(),
|
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(),
|
||||||
current != null && current.isInterBankTotalCapFacility());
|
current != null && current.isInterBankTotalCapFacility());
|
||||||
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility());
|
|
||||||
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc());
|
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc());
|
||||||
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard());
|
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard());
|
||||||
addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking());
|
addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking());
|
||||||
@@ -534,7 +529,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
|
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
|
||||||
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
|
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
|
||||||
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
||||||
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
|
|
||||||
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
||||||
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
||||||
putIfNotNull(fc, "internetBanking", p.getInternetBanking());
|
putIfNotNull(fc, "internetBanking", p.getInternetBanking());
|
||||||
|
|||||||
-1
@@ -91,7 +91,6 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
|||||||
cfg.put("neftFacility", rs.getBoolean("neft_facility"));
|
cfg.put("neftFacility", rs.getBoolean("neft_facility"));
|
||||||
cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility"));
|
cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility"));
|
||||||
cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility"));
|
cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility"));
|
||||||
cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility"));
|
|
||||||
cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc"));
|
cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc"));
|
||||||
cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card"));
|
cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card"));
|
||||||
cfg.put("internetBanking", rs.getBoolean("internet_banking"));
|
cfg.put("internetBanking", rs.getBoolean("internet_banking"));
|
||||||
|
|||||||
-1
@@ -505,7 +505,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
cfg.setNeftFacility(getBool(fcJson, "neftFacility"));
|
cfg.setNeftFacility(getBool(fcJson, "neftFacility"));
|
||||||
cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility"));
|
cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility"));
|
||||||
cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility"));
|
cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility"));
|
||||||
cfg.setWithdrawalFacility(getBool(fcJson, "withdrawalFacility"));
|
|
||||||
cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc"));
|
cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc"));
|
||||||
cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard"));
|
cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard"));
|
||||||
cfg.setInternetBanking(getBool(fcJson, "internetBanking"));
|
cfg.setInternetBanking(getBool(fcJson, "internetBanking"));
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface DeafRemittanceWritePlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the escheated balances of the given accounts were remitted to RBI's DEAF.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Accounts that are not escheated, or already carry a remittance, are skipped rather than
|
||||||
|
* rejected — a batch selected in the UI should not fail whole because one row was sent twice.
|
||||||
|
*/
|
||||||
|
CommandProcessingResult recordRemittance(JsonCommand command);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the customer claimed an already-remitted balance back and the bank recovered it
|
||||||
|
* from the fund.
|
||||||
|
*/
|
||||||
|
CommandProcessingResult markRefunded(Long savingsAccountId, JsonCommand command);
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.DeafRemittance;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.DeafRemittanceRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that escheated balances were remitted to RBI's Depositor Education and Awareness Fund.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The remittance itself is a transfer made outside this system on RBI's own schedule. All that
|
||||||
|
* happens here is the recording of it, which is what lets the escheat report tell money that has
|
||||||
|
* reached the fund from money still sitting with the bank.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeafRemittanceWritePlatformServiceImpl implements DeafRemittanceWritePlatformService {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DeafRemittanceRepository remittanceRepository;
|
||||||
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult recordRemittance(final JsonCommand command) {
|
||||||
|
final Long userId = this.context.authenticatedUser().getId();
|
||||||
|
|
||||||
|
final String[] accountIds = command.arrayValueOfParameterNamed("savingsAccountIds") == null ? new String[0]
|
||||||
|
: command.arrayOfParameterNamed("savingsAccountIds").asList().stream().map(e -> e.getAsString()).toArray(String[]::new);
|
||||||
|
final LocalDate remittedOn = command.localDateValueOfParameterNamed("remittedOn");
|
||||||
|
final String deafReference = command.stringValueOfParameterNamed("deafReference");
|
||||||
|
final String remarks = command.stringValueOfParameterNamed("remarks");
|
||||||
|
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("deafremittance");
|
||||||
|
|
||||||
|
if (accountIds.length == 0) {
|
||||||
|
validator.reset().parameter("savingsAccountIds").failWithCode("cannot.be.empty");
|
||||||
|
}
|
||||||
|
if (remittedOn == null) {
|
||||||
|
validator.reset().parameter("remittedOn").failWithCode("cannot.be.blank");
|
||||||
|
} else if (DateUtils.isAfterBusinessDate(remittedOn)) {
|
||||||
|
// A remittance cannot have been made on a day that has not arrived.
|
||||||
|
validator.reset().parameter("remittedOn").value(remittedOn).failWithCode("cannot.be.in.the.future");
|
||||||
|
}
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
int recorded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (final String rawId : accountIds) {
|
||||||
|
final Long accountId = Long.valueOf(rawId);
|
||||||
|
final SavingsAccount account = this.savingsAccountRepository.findById(accountId).orElse(null);
|
||||||
|
|
||||||
|
if (account == null) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only an escheated balance is owed to the fund; anything else has not left the customer.
|
||||||
|
if (!SavingsAccountSubStatusEnum.ESCHEAT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()))) {
|
||||||
|
log.warn("Savings account {} is not escheated — remittance not recorded", accountId);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A balance goes to the fund once. Recording it twice would overstate what was sent.
|
||||||
|
if (this.remittanceRepository.existsBySavingsAccountId(accountId)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate escheatedOn = account.getClosedOnDate() != null ? account.getClosedOnDate() : remittedOn;
|
||||||
|
|
||||||
|
this.remittanceRepository.save(DeafRemittance.record(accountId, account.getClient().getId(),
|
||||||
|
account.getSummary().getAccountBalance(), escheatedOn, remittedOn, deafReference, remarks, userId));
|
||||||
|
recorded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("DEAF remittance recorded for {} account(s), {} skipped, reference {}", recorded, skipped, deafReference);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId())
|
||||||
|
.with(java.util.Map.of("recorded", recorded, "skipped", skipped)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult markRefunded(final Long savingsAccountId, final JsonCommand command) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final DeafRemittance remittance = this.remittanceRepository.findBySavingsAccountId(savingsAccountId).orElseThrow(
|
||||||
|
() -> new IllegalStateException("No DEAF remittance recorded for savings account " + savingsAccountId));
|
||||||
|
|
||||||
|
final LocalDate refundedOn = command.localDateValueOfParameterNamed("refundedOn");
|
||||||
|
remittance.markRefunded(refundedOn != null ? refundedOn : DateUtils.getBusinessLocalDate());
|
||||||
|
this.remittanceRepository.save(remittance);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(remittance.getId()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the accounts the dormancy job has marked inactive, dormant or escheated.
|
||||||
|
*/
|
||||||
|
public interface DormantAccountReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param subStatus
|
||||||
|
* 100 inactive, 200 dormant, 300 escheat; null for all three
|
||||||
|
* @param officeName
|
||||||
|
* restricts to one branch; null or blank for every branch
|
||||||
|
* @return the matching accounts, longest idle first
|
||||||
|
*/
|
||||||
|
List<DormantAccountData> retrieveDormantAccounts(Integer subStatus, String officeName);
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads back the accounts the dormancy job has marked.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The job writes {@code sub_status} and nothing read it out in bulk, so the branch could not see
|
||||||
|
* which accounts had gone inactive, dormant or escheated. This is that missing read side.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The idle period is recomputed here rather than stored, from the same definition the job uses —
|
||||||
|
* the last transaction of type 1 (deposit) or 2 (withdrawal), which is to say the last one the
|
||||||
|
* customer made. Interest postings and charge recoveries carry other type codes and are excluded,
|
||||||
|
* which is what keeps an account from looking active merely because the bank credited interest to
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DormantAccountReadPlatformServiceImpl implements DormantAccountReadPlatformService {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final DatabaseSpecificSQLGenerator sqlGenerator;
|
||||||
|
|
||||||
|
/** Sub-status codes the dormancy ladder sets. */
|
||||||
|
private static final int SUB_STATUS_INACTIVE = 100;
|
||||||
|
private static final int SUB_STATUS_DORMANT = 200;
|
||||||
|
private static final int SUB_STATUS_ESCHEAT = 300;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) "
|
||||||
|
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
|
||||||
|
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
|
||||||
|
|
||||||
|
final StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
|
||||||
|
sql.append("sa.account_balance_derived as balance, sa.closedon_date as closedOnDate, ");
|
||||||
|
sql.append("c.id as clientId, c.display_name as clientName, c.mobile_no as mobileNo, ");
|
||||||
|
sql.append("o.name as officeName, sp.name as productName, ");
|
||||||
|
sql.append("sp.days_to_inactive as daysToInactive, sp.days_to_dormancy as daysToDormancy, ");
|
||||||
|
sql.append("sp.days_to_escheat as daysToEscheat, ");
|
||||||
|
sql.append("dr.remitted_on as deafRemittedOn, dr.deaf_reference as deafReference, ");
|
||||||
|
sql.append("dr.refunded_on as deafRefundedOn, ");
|
||||||
|
sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, ");
|
||||||
|
sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle ");
|
||||||
|
sql.append("from m_savings_account sa ");
|
||||||
|
sql.append("inner join m_client c on c.id = sa.client_id ");
|
||||||
|
sql.append("inner join m_office o on o.id = c.office_id ");
|
||||||
|
sql.append("inner join m_savings_product sp on sp.id = sa.product_id ");
|
||||||
|
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
|
||||||
|
// and the absence of one is exactly what the report has to show.
|
||||||
|
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
|
||||||
|
// Only the three states the ladder sets. A blocked or a plainly active account is not
|
||||||
|
// dormant and does not belong on these reports.
|
||||||
|
sql.append("where sa.sub_status_enum in (?, ?, ?) ");
|
||||||
|
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
params.add(DateUtils.getBusinessLocalDate());
|
||||||
|
params.add(SUB_STATUS_INACTIVE);
|
||||||
|
params.add(SUB_STATUS_DORMANT);
|
||||||
|
params.add(SUB_STATUS_ESCHEAT);
|
||||||
|
|
||||||
|
if (subStatus != null) {
|
||||||
|
sql.append("and sa.sub_status_enum = ? ");
|
||||||
|
params.add(subStatus);
|
||||||
|
}
|
||||||
|
if (officeName != null && !officeName.isBlank()) {
|
||||||
|
sql.append("and o.name = ? ");
|
||||||
|
params.add(officeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest idle first: those are the accounts nearest their next mark.
|
||||||
|
sql.append("order by daysIdle desc, sa.account_balance_derived desc");
|
||||||
|
|
||||||
|
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class DormantAccountMapper implements RowMapper<DormantAccountData> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final int subStatus = rs.getInt("subStatus");
|
||||||
|
final BigDecimal balance = rs.getBigDecimal("balance");
|
||||||
|
final long daysIdle = rs.getLong("daysIdle");
|
||||||
|
|
||||||
|
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
|
||||||
|
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
|
||||||
|
.officeName(rs.getString("officeName")).productName(rs.getString("productName"))
|
||||||
|
// A null balance reads as zero rather than as missing data — an escheated account
|
||||||
|
// has had its balance taken out and genuinely holds nothing.
|
||||||
|
.balance(balance == null ? BigDecimal.ZERO : balance).mobileNo(rs.getString("mobileNo"))
|
||||||
|
.subStatus(subStatus).subStatusCode(subStatusCode(subStatus))
|
||||||
|
.lastCustomerTransactionDate(rs.getObject("lastCustomerTransactionDate", java.time.LocalDate.class))
|
||||||
|
.daysIdle(daysIdle).daysToInactive(nullableLong(rs, "daysToInactive"))
|
||||||
|
.daysToDormancy(nullableLong(rs, "daysToDormancy")).daysToEscheat(nullableLong(rs, "daysToEscheat"))
|
||||||
|
.closedOnDate(rs.getObject("closedOnDate", java.time.LocalDate.class))
|
||||||
|
.deafRemittedOn(rs.getObject("deafRemittedOn", java.time.LocalDate.class))
|
||||||
|
.deafReference(rs.getString("deafReference"))
|
||||||
|
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
|
||||||
|
final long value = rs.getLong(column);
|
||||||
|
return rs.wasNull() ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String subStatusCode(final int subStatus) {
|
||||||
|
return switch (subStatus) {
|
||||||
|
case SUB_STATUS_INACTIVE -> "savingsAccountSubStatus.inactive";
|
||||||
|
case SUB_STATUS_DORMANT -> "savingsAccountSubStatus.dormant";
|
||||||
|
case SUB_STATUS_ESCHEAT -> "savingsAccountSubStatus.escheat";
|
||||||
|
default -> "savingsAccountSubStatus.none";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-5
@@ -83,7 +83,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
|
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
|
||||||
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
|
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
|
||||||
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
|
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
|
||||||
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
|
|
||||||
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
|
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
|
||||||
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
|
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
|
populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
|
populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
|
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
|
|
||||||
populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
|
populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
|
||||||
populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
|
populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
|
||||||
}
|
}
|
||||||
@@ -370,7 +368,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
putIfPresent(fc, "neftFacility", p.getNeftFacility());
|
putIfPresent(fc, "neftFacility", p.getNeftFacility());
|
||||||
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
|
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
|
||||||
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
||||||
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
|
|
||||||
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
||||||
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
||||||
return fc;
|
return fc;
|
||||||
@@ -397,7 +394,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility());
|
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility());
|
||||||
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility());
|
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility());
|
||||||
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility());
|
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility());
|
||||||
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility());
|
|
||||||
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc());
|
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc());
|
||||||
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard());
|
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard());
|
||||||
return result;
|
return result;
|
||||||
@@ -483,7 +479,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
|
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
|
||||||
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
|
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
|
||||||
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
|
||||||
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
|
|
||||||
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
|
||||||
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
|
||||||
data.setProposedFacilityConfig(fc);
|
data.setProposedFacilityConfig(fc);
|
||||||
|
|||||||
-2
@@ -898,7 +898,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("safc.neft_facility as neftFacility, ");
|
sqlBuilder.append("safc.neft_facility as neftFacility, ");
|
||||||
sqlBuilder.append("safc.rtgs_facility as rtgsFacility, ");
|
sqlBuilder.append("safc.rtgs_facility as rtgsFacility, ");
|
||||||
sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, ");
|
sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, ");
|
||||||
sqlBuilder.append("safc.withdrawal_facility as withdrawalFacility, ");
|
|
||||||
sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, ");
|
sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, ");
|
||||||
sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, ");
|
sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, ");
|
||||||
sqlBuilder.append("sa.terminate_true as terminateTrue ");
|
sqlBuilder.append("sa.terminate_true as terminateTrue ");
|
||||||
@@ -1178,7 +1177,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility"));
|
facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility"));
|
||||||
facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility"));
|
facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility"));
|
||||||
facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility"));
|
facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility"));
|
||||||
facilityConfigMap.put("withdrawalFacility", rs.getBoolean("withdrawalFacility"));
|
|
||||||
facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc"));
|
facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc"));
|
||||||
facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard"));
|
facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard"));
|
||||||
accountData.setAccountFacilityConfig(facilityConfigMap);
|
accountData.setAccountFacilityConfig(facilityConfigMap);
|
||||||
|
|||||||
+15
@@ -112,6 +112,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
|
|||||||
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
|
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||||
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
|
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
|
||||||
@@ -166,6 +168,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
private final AppUserRepositoryWrapper appuserRepository;
|
private final AppUserRepositoryWrapper appuserRepository;
|
||||||
private final StandingInstructionRepository standingInstructionRepository;
|
private final StandingInstructionRepository standingInstructionRepository;
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||||
|
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||||
private final GSIMRepositoy gsimRepository;
|
private final GSIMRepositoy gsimRepository;
|
||||||
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
||||||
private final ErrorHandler errorHandler;
|
private final ErrorHandler errorHandler;
|
||||||
@@ -232,6 +236,17 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new SavingsActivateBusinessEvent(account));
|
businessEventNotifierService.notifyPostBusinessEvent(new SavingsActivateBusinessEvent(account));
|
||||||
|
|
||||||
|
// Open the daily-interest ledger for the account here and now. The nightly run would find it
|
||||||
|
// tomorrow, but until then the account is absent from the ledger, which reads to a branch as
|
||||||
|
// though it is earning nothing. The product is enrolled first: an account whose product has
|
||||||
|
// no posting timetable would accrue with nothing to pay it out.
|
||||||
|
//
|
||||||
|
// Neither call can fail the activation — both run in their own transaction and swallow their
|
||||||
|
// own errors — because an account that is open must stay open even if its ledger row could
|
||||||
|
// not be written.
|
||||||
|
savingsDailyInterestOrchestrator.enrolProductOf(savingsId);
|
||||||
|
savingsDailyInterestService.accrueOnActivation(savingsId, DateUtils.getBusinessLocalDate());
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(savingsId) //
|
.withEntityId(savingsId) //
|
||||||
.withOfficeId(account.officeId()) //
|
.withOfficeId(account.officeId()) //
|
||||||
|
|||||||
-1
@@ -140,7 +140,6 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead
|
|||||||
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);
|
||||||
|
|||||||
+15
@@ -52,6 +52,7 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
|
|||||||
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.portfolio.savings.data.SavingsProductDataValidator;
|
import org.apache.fineract.portfolio.savings.data.SavingsProductDataValidator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc;
|
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
|
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
|
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
|
||||||
@@ -103,6 +104,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository;
|
private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository;
|
||||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||||
private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository;
|
private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository;
|
||||||
|
private final SavingsProductTermsCascadeService termsCascadeService;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
||||||
@@ -295,6 +297,19 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.savingProductRepository.saveAndFlush(product);
|
this.savingProductRepository.saveAndFlush(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interest terms are copied onto each account at creation and the engine reads that
|
||||||
|
// copy, so a rate or posting-period change here would otherwise leave every existing
|
||||||
|
// account on the terms it was opened with. Interest follows the product at this bank,
|
||||||
|
// so the edit is pushed down. Same transaction as the save: the product and its
|
||||||
|
// accounts move together or not at all.
|
||||||
|
//
|
||||||
|
// approveEdit() replays a maker-checker edit through this same method, so the checker
|
||||||
|
// path is covered by this one call.
|
||||||
|
final int accountsRealigned = this.termsCascadeService.cascadeInterestTerms(product.getId(), changes);
|
||||||
|
if (accountsRealigned > 0) {
|
||||||
|
changes.put("accountsRealignedToProductTerms", accountsRealigned);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(product.getId()) //
|
.withEntityId(product.getId()) //
|
||||||
.with(changes).build();
|
.with(changes).build();
|
||||||
|
|||||||
+8
-3
@@ -84,6 +84,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
|
|||||||
import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler;
|
import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository;
|
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository;
|
||||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository;
|
import org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository;
|
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository;
|
||||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository;
|
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository;
|
||||||
@@ -473,6 +475,7 @@ public class SavingsConfiguration {
|
|||||||
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
||||||
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
|
SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator, SavingsDailyInterestService savingsDailyInterestService,
|
||||||
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
||||||
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
|
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
|
||||||
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
|
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
|
||||||
@@ -482,7 +485,8 @@ public class SavingsConfiguration {
|
|||||||
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
||||||
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
||||||
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
||||||
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
|
standingInstructionRepository, businessEventNotifierService, savingsDailyInterestOrchestrator,
|
||||||
|
savingsDailyInterestService, gsimRepository, savingsAccountInterestPostingService,
|
||||||
errorHandler, denominationBreakdownWriter);
|
errorHandler, denominationBreakdownWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,12 +580,13 @@ public class SavingsConfiguration {
|
|||||||
org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository,
|
org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||||
org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
||||||
org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||||
org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository) {
|
org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||||
|
org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService termsCascadeService) {
|
||||||
return new SavingsProductWritePlatformServiceJpaRepositoryImpl(context, savingProductRepository, fromApiJsonDataValidator,
|
return new SavingsProductWritePlatformServiceJpaRepositoryImpl(context, savingProductRepository, fromApiJsonDataValidator,
|
||||||
savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository,
|
savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository,
|
||||||
facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper,
|
facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper,
|
||||||
commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository,
|
commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository,
|
||||||
pendingEditSubmittedFieldRepository);
|
pendingEditSubmittedFieldRepository, termsCascadeService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+74
-1
@@ -50,7 +50,9 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
|
||||||
@@ -70,6 +72,7 @@ public class SponsorDDApiResource {
|
|||||||
private final SponsorDDReconciliationService reconciliationService;
|
private final SponsorDDReconciliationService reconciliationService;
|
||||||
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
||||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||||
|
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
@@ -233,6 +236,18 @@ public class SponsorDDApiResource {
|
|||||||
return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId()));
|
return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("leaflets/next-available")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve next available DD leaflet", description = "Returns the next sequential available leaflet for a branch and sponsor bank.")
|
||||||
|
public SponsorDDLeafletData retrieveNextAvailableLeaflet(
|
||||||
|
@QueryParam("branchId") final Long branchId,
|
||||||
|
@QueryParam("sponsorBankId") final Long sponsorBankId,
|
||||||
|
@QueryParam("inventoryId") final Long inventoryId) {
|
||||||
|
return this.readPlatformService.retrieveNextAvailableLeaflet(branchId, sponsorBankId, inventoryId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ──
|
// ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ──
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -260,7 +275,8 @@ public class SponsorDDApiResource {
|
|||||||
request.getCurrentAccountId(),
|
request.getCurrentAccountId(),
|
||||||
request.getChequeNumber(),
|
request.getChequeNumber(),
|
||||||
request.getChequeBankName(),
|
request.getChequeBankName(),
|
||||||
request.getCashierDepositRequestId()
|
request.getCashierDepositRequestId(),
|
||||||
|
request.getDenominationData()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +426,48 @@ public class SponsorDDApiResource {
|
|||||||
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("leaflets/{leafletId}/amendment-request")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Request an amendment to an issued DD", description = "Clerk (maker) step: requests manager approval to correct the beneficiary name, or the branch and city the draft is payable at. The draft is unchanged until approved.")
|
||||||
|
public CommandProcessingResult requestAmendment(@PathParam("leafletId") final Long leafletId,
|
||||||
|
final AmendmentRequestRequest request) {
|
||||||
|
return safely(() -> this.amendmentRequestWritePlatformService.requestAmendment(leafletId, request.getBeneficiaryName(),
|
||||||
|
request.getPayableAtBranch(), request.getPayableAtCity(), request.getReason()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("amendment-requests")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve DD amendment requests", description = "Returns DD amendment requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue. Each row carries both the old and the new details.")
|
||||||
|
public Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(@QueryParam("status") final String status) {
|
||||||
|
return this.readPlatformService.retrieveAllAmendmentRequests(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("amendment-requests/{amendmentRequestId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve a DD amendment request", description = "Manager/head-cashier (checker) step: applies the corrected details to the draft.")
|
||||||
|
public CommandProcessingResult approveAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId) {
|
||||||
|
return safely(() -> this.amendmentRequestWritePlatformService.approveAmendmentRequest(amendmentRequestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("amendment-requests/{amendmentRequestId}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject a DD amendment request", description = "Manager/head-cashier (checker) step: refuses the correction, leaving the draft as issued.")
|
||||||
|
public CommandProcessingResult rejectAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId,
|
||||||
|
final RejectIssueRequestRequest request) {
|
||||||
|
return safely(() -> this.amendmentRequestWritePlatformService.rejectAmendmentRequest(amendmentRequestId,
|
||||||
|
request.getRejectionReason()));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sponsor DD GL account configuration ───────────────────────────────
|
// ── Sponsor DD GL account configuration ───────────────────────────────
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -568,6 +626,7 @@ public class SponsorDDApiResource {
|
|||||||
// CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request
|
// CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request
|
||||||
// that received this cash, credited to the DD Cash Suspense GL account.
|
// that received this cash, credited to the DD Cash Suspense GL account.
|
||||||
private Long cashierDepositRequestId;
|
private Long cashierDepositRequestId;
|
||||||
|
private String denominationData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
@@ -602,6 +661,20 @@ public class SponsorDDApiResource {
|
|||||||
private String reason;
|
private String reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three details of an issued draft that can be corrected.
|
||||||
|
*
|
||||||
|
* A field left blank keeps what the draft already carries, so correcting only the city does not
|
||||||
|
* mean retyping the beneficiary and risking a second mistake.
|
||||||
|
*/
|
||||||
|
@lombok.Data
|
||||||
|
public static class AmendmentRequestRequest {
|
||||||
|
private String beneficiaryName;
|
||||||
|
private String payableAtBranch;
|
||||||
|
private String payableAtCity;
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
|
|
||||||
@lombok.Data
|
@lombok.Data
|
||||||
public static class GlMappingRequest {
|
public static class GlMappingRequest {
|
||||||
private Long sponsorBankId;
|
private Long sponsorBankId;
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sponsordd.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending or settled correction to an issued Demand Draft.
|
||||||
|
*
|
||||||
|
* Carries both the new details and the ones they replace, so the approval queue can show a checker
|
||||||
|
* what is actually changing rather than only what it would become — and so the record still reads
|
||||||
|
* correctly after the leaflet itself has been updated.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SponsorDDAmendmentRequestData implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private Long leafletId;
|
||||||
|
private String ddNumber;
|
||||||
|
|
||||||
|
private String beneficiaryName;
|
||||||
|
private String payableAtBranch;
|
||||||
|
private String payableAtCity;
|
||||||
|
|
||||||
|
private String oldBeneficiaryName;
|
||||||
|
private String oldPayableAtBranch;
|
||||||
|
private String oldPayableAtCity;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
private String status;
|
||||||
|
private Long makerId;
|
||||||
|
private String makerUsername;
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
private Long checkerId;
|
||||||
|
private String checkerUsername;
|
||||||
|
private LocalDateTime checkerDate;
|
||||||
|
private String rejectionReason;
|
||||||
|
}
|
||||||
+6
@@ -35,16 +35,22 @@ public class SponsorDDGlMappingData implements Serializable {
|
|||||||
private String sponsorBankName;
|
private String sponsorBankName;
|
||||||
private Long ddPayableGlAccountId;
|
private Long ddPayableGlAccountId;
|
||||||
private String ddPayableGlAccountName;
|
private String ddPayableGlAccountName;
|
||||||
|
private String ddPayableGlCode;
|
||||||
private Long chequeClearingGlAccountId;
|
private Long chequeClearingGlAccountId;
|
||||||
private String chequeClearingGlAccountName;
|
private String chequeClearingGlAccountName;
|
||||||
|
private String chequeClearingGlCode;
|
||||||
private Long cashGlAccountId;
|
private Long cashGlAccountId;
|
||||||
private String cashGlAccountName;
|
private String cashGlAccountName;
|
||||||
|
private String cashGlCode;
|
||||||
private Long chargesIncomeGlAccountId;
|
private Long chargesIncomeGlAccountId;
|
||||||
private String chargesIncomeGlAccountName;
|
private String chargesIncomeGlAccountName;
|
||||||
|
private String chargesIncomeGlCode;
|
||||||
private Long cashSuspenseGlAccountId;
|
private Long cashSuspenseGlAccountId;
|
||||||
private String cashSuspenseGlAccountName;
|
private String cashSuspenseGlAccountName;
|
||||||
|
private String cashSuspenseGlCode;
|
||||||
private BigDecimal gstPercentage;
|
private BigDecimal gstPercentage;
|
||||||
private Long gstPayableGlAccountId;
|
private Long gstPayableGlAccountId;
|
||||||
private String gstPayableGlAccountName;
|
private String gstPayableGlAccountName;
|
||||||
|
private String gstPayableGlCode;
|
||||||
private BigDecimal ddLimitAmount;
|
private BigDecimal ddLimitAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -57,4 +57,6 @@ public class SponsorDDLeafletData implements Serializable {
|
|||||||
private Long approvedUnconsumedReprintRequestId;
|
private Long approvedUnconsumedReprintRequestId;
|
||||||
private Long approverUserId;
|
private Long approverUserId;
|
||||||
private String approverUsername;
|
private String approverUsername;
|
||||||
|
/** Set while a correction to this draft is awaiting a manager. */
|
||||||
|
private Long pendingAmendmentRequestId;
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sponsordd.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrections to an already-issued Demand Draft, under maker-checker.
|
||||||
|
*
|
||||||
|
* Only the beneficiary's name and the branch and city the draft is payable at can be amended. Those
|
||||||
|
* are details of who may present the instrument and where; getting one wrong makes the draft
|
||||||
|
* unpresentable, and the purchaser is already holding it by the time anyone notices.
|
||||||
|
*
|
||||||
|
* Face value, purchaser and funding are deliberately not amendable. The money for those has already
|
||||||
|
* moved, and editing them would leave the ledger disagreeing with the instrument — a draft wrong in
|
||||||
|
* that way is cancelled and re-issued instead.
|
||||||
|
*/
|
||||||
|
public interface SponsorDDAmendmentRequestWritePlatformService {
|
||||||
|
|
||||||
|
/** Maker step: records what the clerk wants changed, leaving the leaflet untouched. */
|
||||||
|
CommandProcessingResult requestAmendment(Long leafletId, String beneficiaryName, String payableAtBranch, String payableAtCity,
|
||||||
|
String reason);
|
||||||
|
|
||||||
|
/** Checker step: applies the change to the leaflet. */
|
||||||
|
CommandProcessingResult approveAmendmentRequest(Long amendmentRequestId);
|
||||||
|
|
||||||
|
/** Checker step: refuses the change, leaving the draft as issued. */
|
||||||
|
CommandProcessingResult rejectAmendmentRequest(Long amendmentRequestId, String rejectionReason);
|
||||||
|
}
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sponsordd.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SponsorDDAmendmentRequestWritePlatformServiceImpl implements SponsorDDAmendmentRequestWritePlatformService {
|
||||||
|
|
||||||
|
private static final String STATUS_PENDING = "PENDING";
|
||||||
|
private static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
private static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult requestAmendment(final Long leafletId, final String beneficiaryName,
|
||||||
|
final String payableAtBranch, final String payableAtCity, final String reason) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
|
||||||
|
|
||||||
|
// Only an issued draft is amendable. A cancelled or stopped one is out of circulation and a
|
||||||
|
// cleared one has already been paid, so correcting where it is payable would mean nothing.
|
||||||
|
if (!"ISSUED".equals(leaflet.get("status"))) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Only an issued Demand Draft can be amended. Current status: " + leaflet.get("status"));
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("A reason is required to amend an issued Demand Draft.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String newBeneficiary = trimToNull(beneficiaryName);
|
||||||
|
final String newBranch = trimToNull(payableAtBranch);
|
||||||
|
final String newCity = trimToNull(payableAtCity);
|
||||||
|
if (newBeneficiary == null && newBranch == null && newCity == null) {
|
||||||
|
throw new IllegalArgumentException("Enter the beneficiary name, the payable-at branch or the city to amend.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String oldBeneficiary = (String) leaflet.get("beneficiary_name");
|
||||||
|
final String oldBranch = (String) leaflet.get("payable_at_branch");
|
||||||
|
final String oldCity = (String) leaflet.get("payable_at_city");
|
||||||
|
|
||||||
|
// An amendment that changes nothing still costs a checker's attention, so it is refused
|
||||||
|
// rather than queued.
|
||||||
|
if (Objects.equals(newBeneficiary, oldBeneficiary) && Objects.equals(newBranch, oldBranch)
|
||||||
|
&& Objects.equals(newCity, oldCity)) {
|
||||||
|
throw new IllegalArgumentException("The details submitted are the same as the ones on the draft.");
|
||||||
|
}
|
||||||
|
if (!this.jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_sponsor_dd_amendment_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
|
||||||
|
.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"An amendment is already pending for this Demand Draft. Wait for it to be approved or rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank fields keep what the draft already carries, so a clerk correcting only the city does
|
||||||
|
// not have to retype the beneficiary and risk a second mistake.
|
||||||
|
final String finalBeneficiary = newBeneficiary != null ? newBeneficiary : oldBeneficiary;
|
||||||
|
final String finalBranch = newBranch != null ? newBranch : oldBranch;
|
||||||
|
final String finalCity = newCity != null ? newCity : oldCity;
|
||||||
|
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(con -> {
|
||||||
|
final java.sql.PreparedStatement ps = con.prepareStatement("INSERT INTO m_sponsor_dd_amendment_request "
|
||||||
|
+ "(leaflet_id, beneficiary_name, payable_at_branch, payable_at_city, "
|
||||||
|
+ " old_beneficiary_name, old_payable_at_branch, old_payable_at_city, "
|
||||||
|
+ " reason, status, maker_id, created_date) VALUES (?,?,?,?,?,?,?,?,?,?,?)", new String[] { "id" });
|
||||||
|
ps.setLong(1, leafletId);
|
||||||
|
ps.setString(2, finalBeneficiary);
|
||||||
|
ps.setString(3, finalBranch);
|
||||||
|
ps.setString(4, finalCity);
|
||||||
|
ps.setString(5, oldBeneficiary);
|
||||||
|
ps.setString(6, oldBranch);
|
||||||
|
ps.setString(7, oldCity);
|
||||||
|
ps.setString(8, reason.trim());
|
||||||
|
ps.setString(9, STATUS_PENDING);
|
||||||
|
ps.setLong(10, currentUser.getId());
|
||||||
|
ps.setObject(11, now);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
|
||||||
|
final long newId = Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(newId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult approveAmendmentRequest(final Long amendmentRequestId) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findAmendmentRequestOrThrow(amendmentRequestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot approve your own amendment request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long leafletId = ((Number) request.get("leaflet_id")).longValue();
|
||||||
|
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
|
||||||
|
// Re-checked at approval: the draft may have been cancelled or stopped while the request sat
|
||||||
|
// in the queue, and amending one that has left circulation would be a silent no-op.
|
||||||
|
if (!"ISSUED".equals(leaflet.get("status"))) {
|
||||||
|
throw new IllegalStateException("This Demand Draft is no longer issued. Current status: " + leaflet.get("status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_sponsor_dd_leaflet SET beneficiary_name = ?, payable_at_branch = ?, payable_at_city = ? WHERE id = ?",
|
||||||
|
request.get("beneficiary_name"), request.get("payable_at_branch"), request.get("payable_at_city"), leafletId);
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
|
||||||
|
STATUS_APPROVED, currentUser.getId(), now, amendmentRequestId);
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).withSubEntityId(leafletId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public CommandProcessingResult rejectAmendmentRequest(final Long amendmentRequestId, final String rejectionReason) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> request = findAmendmentRequestOrThrow(amendmentRequestId);
|
||||||
|
if (!STATUS_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) request.get("maker_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new IllegalStateException("You cannot reject your own amendment request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
this.jdbcTemplate.update("UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ?, "
|
||||||
|
+ "rejection_reason = ? WHERE id = ?", STATUS_REJECTED, currentUser.getId(), now, rejectionReason,
|
||||||
|
amendmentRequestId);
|
||||||
|
return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, status, beneficiary_name, payable_at_branch, payable_at_city FROM m_sponsor_dd_leaflet WHERE id = ?",
|
||||||
|
leafletId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Demand Draft leaflet not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> findAmendmentRequestOrThrow(final Long amendmentRequestId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM m_sponsor_dd_amendment_request WHERE id = ?", amendmentRequestId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Sponsor DD amendment request not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCheckerRole(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCheckerRole(final AppUser user) {
|
||||||
|
if (!isCheckerRole(user)) {
|
||||||
|
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject amendment requests.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
|
|||||||
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
|
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
|
||||||
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
|
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
|
||||||
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
|
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
|
||||||
Long cashierDepositRequestId);
|
Long cashierDepositRequestId, String denominationData);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
|
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
|
||||||
|
|||||||
+227
-25
@@ -101,7 +101,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
|
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
|
||||||
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
|
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
|
||||||
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
|
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
|
||||||
final Long cashierDepositRequestId) {
|
final Long cashierDepositRequestId, final String denominationData) {
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
|
||||||
@@ -138,13 +138,45 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
String walkinPurchaserAadhaarMasked = null;
|
String walkinPurchaserAadhaarMasked = null;
|
||||||
Long cashReceiptJournalEntryId = null;
|
Long cashReceiptJournalEntryId = null;
|
||||||
|
// Set when this service posts the counter receipt itself, so the receipt slip is raised once the
|
||||||
|
// request row exists to key it by. A receipt against a separate cashier deposit voucher is not
|
||||||
|
// included: that register raised its own slip when it took the notes.
|
||||||
|
boolean cashReceiptVoucherPending = false;
|
||||||
if (METHOD_CASH.equals(paymentMethod)) {
|
if (METHOD_CASH.equals(paymentMethod)) {
|
||||||
walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar);
|
walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar);
|
||||||
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
|
||||||
final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount);
|
final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount);
|
||||||
|
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
||||||
|
if (cashierDepositRequestId != null) {
|
||||||
final Map<String, Object> depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal,
|
final Map<String, Object> depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal,
|
||||||
cashSuspenseGlAccountId);
|
cashSuspenseGlAccountId);
|
||||||
cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId);
|
cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId);
|
||||||
|
} else {
|
||||||
|
// Taking cash straight over the counter, with no deposit request to have counted it.
|
||||||
|
// The breakdown is the only record of which notes were handed over, and the closing
|
||||||
|
// report is built entirely from breakdowns, so a draft accepted without one puts
|
||||||
|
// money in the drawer that no cash report can see. It was optional here, and every
|
||||||
|
// draft raised this way went in blank.
|
||||||
|
// An empty object counts as absent: a caller that sends "{}" has told us nothing
|
||||||
|
// about the notes, and the amount check below would pass it as a zero total.
|
||||||
|
if (denominationData == null || denominationData.isBlank() || denominationData.replaceAll("\\s", "").equals("{}")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Enter the note and coin breakdown for the cash taken, or fund this draft from an approved cashier deposit request.");
|
||||||
|
}
|
||||||
|
validateDenominationTotal(denominationData, expectedTotal);
|
||||||
|
// The purchaser has handed over the notes to get this far, so the receipt is posted
|
||||||
|
// now rather than at approval. Cash in the drawer that the ledger does not know about
|
||||||
|
// is a cash difference at the end of the day, and if the request is later rejected
|
||||||
|
// there is nothing for rejectIssueRequest() to refund against.
|
||||||
|
//
|
||||||
|
// It lands in the cash-suspense account, not DD Payable: the draft has not been
|
||||||
|
// approved, so the bank holds the money without yet owing it on an instrument.
|
||||||
|
// Approval moves it on; rejection refunds it.
|
||||||
|
final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
|
||||||
|
final String receiptComments = "Cash received for DD issuance request - " + beneficiaryName;
|
||||||
|
cashReceiptJournalEntryId = postGlOffsetEntry(cashGlAccountId, cashSuspenseGlAccountId, expectedTotal, null, null,
|
||||||
|
issuingBranchId, issuedDate, receiptComments, null);
|
||||||
|
cashReceiptVoucherPending = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
validateClientExists(clientId);
|
validateClientExists(clientId);
|
||||||
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
|
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
|
||||||
@@ -168,8 +200,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
|
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
|
||||||
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
|
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
|
||||||
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
|
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
|
||||||
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id) "
|
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
|
||||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
new String[] { "id" });
|
new String[] { "id" });
|
||||||
ps.setLong(1, leafletId);
|
ps.setLong(1, leafletId);
|
||||||
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
|
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
|
||||||
@@ -195,10 +227,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
ps.setObject(22, now);
|
ps.setObject(22, now);
|
||||||
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
|
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
|
||||||
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
|
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
|
||||||
|
ps.setString(25, denominationData);
|
||||||
return ps;
|
return ps;
|
||||||
}, kh);
|
}, kh);
|
||||||
|
|
||||||
final long newId = Objects.requireNonNull(kh.getKey()).longValue();
|
final long newId = Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
if (METHOD_CASH.equals(paymentMethod) && cashierDepositRequestId == null && denominationData != null && !denominationData.isBlank()) {
|
||||||
|
savePendingDenomination(denominationData, newId, currentUser.getId(), currentUser.getUsername());
|
||||||
|
}
|
||||||
|
if (cashReceiptVoucherPending) {
|
||||||
|
raiseCashReceiptVoucher(newId, beneficiaryName, faceValue.add(effectiveCharges).add(gstAmount),
|
||||||
|
((Number) glMapping.get("cash_gl_account_id")).longValue(),
|
||||||
|
((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(), currentUser);
|
||||||
|
}
|
||||||
return new CommandProcessingResultBuilder().withEntityId(newId).build();
|
return new CommandProcessingResultBuilder().withEntityId(newId).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,9 +541,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
// and what is still unclaimed as two separate balances rather than only the net.
|
// and what is still unclaimed as two separate balances rather than only the net.
|
||||||
drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber);
|
drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber);
|
||||||
} else {
|
} else {
|
||||||
// Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the
|
// Cash payment. The money was taken at submission and is sitting in the cash-suspense
|
||||||
// request was submitted, so approval clears the suspense rather than debiting cash again.
|
// account, whichever way it got there — a separate cashier deposit voucher, or the
|
||||||
|
// receipt this service posts when the purchaser pays at the counter. Approval moves it
|
||||||
|
// out of suspense onto the instrument.
|
||||||
|
//
|
||||||
|
// Debiting the cash account here instead would count the same notes twice: once when
|
||||||
|
// they were received, once again now.
|
||||||
|
if (request.get("cash_receipt_journal_entry_id") != null || request.get("cashier_deposit_request_id") != null) {
|
||||||
drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
||||||
|
} else {
|
||||||
|
// A request raised before the receipt was posted at submission: the cash never went
|
||||||
|
// through suspense, so it comes straight off the cash account.
|
||||||
|
drGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
|
||||||
@@ -523,6 +575,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
|
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
|
||||||
resultingJournalEntryId, issueRequestId);
|
resultingJournalEntryId, issueRequestId);
|
||||||
|
|
||||||
|
if (resultingJournalEntryId != null) {
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ? AND account_no = 'DD-ISSUE'",
|
||||||
|
resultingJournalEntryId, issueRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
||||||
settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName());
|
settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName());
|
||||||
}
|
}
|
||||||
@@ -609,8 +667,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
|
final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
|
||||||
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
|
||||||
final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser";
|
final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser";
|
||||||
|
final BigDecimal refundTotal = faceValue.add(effectiveCharges).add(effectiveGst);
|
||||||
cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId,
|
cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId,
|
||||||
faceValue.add(effectiveCharges).add(effectiveGst), null, null, issuingBranchId, issuedDate, refundComments, null);
|
refundTotal, null, null, issuingBranchId, issuedDate, refundComments, null);
|
||||||
|
raiseCashRefundVoucher(issueRequestId, refundTotal, cashSuspenseGlAccountId, cashGlAccountId, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
@@ -782,21 +842,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
return requests;
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GL mapping configuration ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CommandProcessingResult upsertGlMapping(final Long paymentTypeId, final Long ddPayableGlAccountId,
|
public CommandProcessingResult upsertGlMapping(final Long sponsorBankId, final Long ddPayableGlAccountId,
|
||||||
final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId,
|
final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId,
|
||||||
final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId,
|
final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId,
|
||||||
final BigDecimal ddLimitAmount) {
|
final BigDecimal ddLimitAmount) {
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
validateCheckerRole(currentUser);
|
if (ddPayableGlAccountId == null) {
|
||||||
if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) {
|
throw new IllegalArgumentException("Sponsor DD Payable GL account is required.");
|
||||||
throw new IllegalArgumentException("GST percentage must be between 0 and 100.");
|
|
||||||
}
|
|
||||||
if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) < 0) {
|
|
||||||
throw new IllegalArgumentException("DD Limit Amount cannot be negative.");
|
|
||||||
}
|
}
|
||||||
final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18);
|
final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18);
|
||||||
|
|
||||||
@@ -839,6 +893,82 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
|
|
||||||
// ── Validation helpers ─────────────────────────────────────────────────
|
// ── Validation helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private final com.google.gson.Gson gson = new com.google.gson.Gson();
|
||||||
|
|
||||||
|
private void validateDenominationTotal(final String denominationData, final BigDecimal expectedTotal) {
|
||||||
|
if (denominationData == null || denominationData.isBlank()) return;
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = this.gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("Denomination data is not valid JSON.");
|
||||||
|
}
|
||||||
|
if (denomMap == null || denomMap.size() == 0) return;
|
||||||
|
|
||||||
|
long total = 0;
|
||||||
|
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final int count;
|
||||||
|
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
|
||||||
|
if (count <= 0) continue;
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) continue;
|
||||||
|
final int value;
|
||||||
|
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
|
||||||
|
total += (long) value * count;
|
||||||
|
}
|
||||||
|
// Cash is collected in whole rupees, so the breakdown is checked against the payable amount
|
||||||
|
// rounded up rather than against the paise. A GST-bearing draft comes to something like
|
||||||
|
// ₹111.80, and no combination of notes and coins reaches that -- demanding an exact match
|
||||||
|
// made the breakdown impossible to satisfy for the very drafts that most need one. Either
|
||||||
|
// figure is accepted, so a draft that happens to land on a whole rupee still balances.
|
||||||
|
final BigDecimal roundedUp = expectedTotal.setScale(0, java.math.RoundingMode.CEILING);
|
||||||
|
final BigDecimal counted = BigDecimal.valueOf(total);
|
||||||
|
if (counted.compareTo(expectedTotal) != 0 && counted.compareTo(roundedUp) != 0) {
|
||||||
|
throw new IllegalArgumentException("Total cash denomination (₹" + total + ") does not match total payable amount (₹"
|
||||||
|
+ expectedTotal + ", collected as ₹" + roundedUp + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void savePendingDenomination(final String denominationData, final Long issueRequestId,
|
||||||
|
final Long cashierId, final String cashierUsername) {
|
||||||
|
if (denominationData == null || denominationData.isBlank()) return;
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = this.gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (denomMap == null || denomMap.size() == 0) return;
|
||||||
|
|
||||||
|
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final int count;
|
||||||
|
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
|
||||||
|
if (count <= 0) continue;
|
||||||
|
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) continue;
|
||||||
|
final String kind = parts[0];
|
||||||
|
final int value;
|
||||||
|
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
|
||||||
|
|
||||||
|
final List<Long> ids = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
|
||||||
|
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
||||||
|
Long.class, value, kind);
|
||||||
|
if (ids.isEmpty()) continue;
|
||||||
|
final Long denomId = ids.get(0);
|
||||||
|
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"INSERT INTO m_savings_txn_denomination "
|
||||||
|
+ "(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) "
|
||||||
|
+ "VALUES (?, NULL, 'DD-ISSUE', NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, 'CASH')",
|
||||||
|
issueRequestId, denomId, value, count, BigDecimal.valueOf((long) value * count),
|
||||||
|
cashierId, cashierUsername
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
"SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
|
"SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
|
||||||
@@ -868,25 +998,25 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
+ "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1");
|
+ "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1");
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"No Sponsor DD GL mapping is configured for this sponsor bank. Ask an administrator to configure the Sponsor DD Payable GL account first.");
|
"DD GL mapping is not configured. Ask an administrator to configure the Sponsor DD GL accounts in Admin > Sponsor DD GST / GL Settings first.");
|
||||||
}
|
}
|
||||||
final Map<String, Object> mapping = rows.get(0);
|
final Map<String, Object> mapping = rows.get(0);
|
||||||
|
if (mapping.get("dd_payable_gl_account_id") == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No Sponsor DD Payable GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.");
|
||||||
|
}
|
||||||
if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) {
|
if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"No cheque-clearing GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cheque payments.");
|
"No cheque-clearing GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cheque payments.");
|
||||||
}
|
}
|
||||||
if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) {
|
if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"No cash-in-hand GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments.");
|
"No cash-in-hand GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cash payments.");
|
||||||
}
|
|
||||||
if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_suspense_gl_account_id") == null) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"No cash-received-pending-approval GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments.");
|
|
||||||
}
|
}
|
||||||
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
|
||||||
if (hasCharges && mapping.get("charges_income_gl_account_id") == null) {
|
if (hasCharges && mapping.get("charges_income_gl_account_id") == null) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"No charges/commission income GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges.");
|
"No charges/commission income GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before collecting DD issuance charges.");
|
||||||
}
|
}
|
||||||
final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage");
|
final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage");
|
||||||
if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0
|
if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0
|
||||||
@@ -1217,6 +1347,72 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
currentAccountId);
|
currentAccountId);
|
||||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Records the cash taken over the counter for a Demand Draft request in the voucher register.
|
||||||
|
*
|
||||||
|
* A receipt (CR), because the prefix names what happened to cash: the purchaser has handed notes
|
||||||
|
* over and the drawer is heavier. The approval slip raised later is a journal voucher — by then
|
||||||
|
* the money is only moving between GLs — and a rejection is a payment, because the notes go back.
|
||||||
|
*
|
||||||
|
* The credit is cash-suspense rather than DD Payable. The draft has not been approved, so the bank
|
||||||
|
* holds the money without yet owing it on an instrument.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the submission: the notes are counted and the ledger already has
|
||||||
|
* the receipt, and refusing the request over a slip would leave the purchaser's money unrecorded.
|
||||||
|
*/
|
||||||
|
private void raiseCashReceiptVoucher(final Long issueRequestId, final String beneficiaryName, final BigDecimal total,
|
||||||
|
final Long cashGlAccountId, final Long cashSuspenseGlAccountId, final AppUser currentUser) {
|
||||||
|
try {
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
final Map<String, Object> cash = this.voucherService.glAccount(cashGlAccountId);
|
||||||
|
lines.add(VoucherLine.debitGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
|
||||||
|
.withDescription("Cash received for DD to " + beneficiaryName));
|
||||||
|
|
||||||
|
final Map<String, Object> suspense = this.voucherService.glAccount(cashSuspenseGlAccountId);
|
||||||
|
lines.add(VoucherLine
|
||||||
|
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
|
||||||
|
.withDescription("Held pending DD approval"));
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||||
|
currentUser, VoucherService.PREFIX_CREDIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records cash handed back to the purchaser when a Demand Draft request is rejected.
|
||||||
|
*
|
||||||
|
* A payment (DR): the notes leave the drawer. The receipt raised at submission stands — the money
|
||||||
|
* was genuinely taken and genuinely returned, and cancelling the receipt instead would leave the
|
||||||
|
* day's cash movements unaccounted for in both directions.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the rejection, for the same reason it does not fail an issue: the
|
||||||
|
* refund is already posted, and holding a rejection over a slip would leave the purchaser's money
|
||||||
|
* in suspense with no way out.
|
||||||
|
*/
|
||||||
|
private void raiseCashRefundVoucher(final Long issueRequestId, final BigDecimal total, final Long cashSuspenseGlAccountId,
|
||||||
|
final Long cashGlAccountId, final AppUser currentUser) {
|
||||||
|
try {
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
final Map<String, Object> suspense = this.voucherService.glAccount(cashSuspenseGlAccountId);
|
||||||
|
lines.add(VoucherLine
|
||||||
|
.debitGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
|
||||||
|
.withDescription("DD request #" + issueRequestId + " rejected"));
|
||||||
|
|
||||||
|
final Map<String, Object> cash = this.voucherService.glAccount(cashGlAccountId);
|
||||||
|
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
|
||||||
|
.withDescription("Cash refunded to purchaser"));
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||||
|
currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records an approved Demand Draft issue in the voucher register.
|
* Records an approved Demand Draft issue in the voucher register.
|
||||||
*
|
*
|
||||||
@@ -1232,6 +1428,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
* A failure here does not fail the issue. The draft is already raised, the leaflet already
|
* A failure here does not fail the issue. The draft is already raised, the leaflet already
|
||||||
* consumed and the ledger already posted; withdrawing that over a slip would take back a
|
* consumed and the ledger already posted; withdrawing that over a slip would take back a
|
||||||
* completed instrument for a paperwork reason. The problem is logged and the issue stands.
|
* completed instrument for a paperwork reason. The problem is logged and the issue stands.
|
||||||
|
*
|
||||||
|
* Raised as a payment (DR), whichever way the draft was funded. For a DD funded from the
|
||||||
|
* purchaser's account or by cheque that is what it is — the money leaves at approval. For one
|
||||||
|
* paid in cash the notes were receipted at submit and approval only moves them out of suspense,
|
||||||
|
* so the register keeps two slips over the same money: a CR when it came in and this DR when it
|
||||||
|
* was applied. Read the two together, not the DR alone, when reconciling a day's cash.
|
||||||
*/
|
*/
|
||||||
private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId,
|
private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId,
|
||||||
final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId,
|
final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId,
|
||||||
|
|||||||
+6
@@ -24,6 +24,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
|
|
||||||
public interface SponsorDDReadPlatformService {
|
public interface SponsorDDReadPlatformService {
|
||||||
@@ -58,6 +59,11 @@ public interface SponsorDDReadPlatformService {
|
|||||||
|
|
||||||
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
|
||||||
|
|
||||||
|
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
|
||||||
|
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
|
||||||
|
|
||||||
|
SponsorDDLeafletData retrieveNextAvailableLeaflet(Long branchId, Long sponsorBankId, Long inventoryId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash
|
* Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash
|
||||||
* Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk
|
* Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk
|
||||||
|
|||||||
+106
-11
@@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData;
|
|||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
|
||||||
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
@@ -81,7 +82,8 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
+ "leaf.issuing_branch_id, o.name as branch_name, "
|
+ "leaf.issuing_branch_id, o.name as branch_name, "
|
||||||
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
|
||||||
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
|
||||||
+ "apr.checker_id as approver_user_id, approver.username as approver_username "
|
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
|
||||||
|
+ "amr.id as pending_amendment_request_id "
|
||||||
+ "from m_sponsor_dd_leaflet leaf "
|
+ "from m_sponsor_dd_leaflet leaf "
|
||||||
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
+ "left join m_client c on c.id = leaf.issued_to_client_id "
|
||||||
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
+ "left join m_office o on o.id = leaf.issuing_branch_id "
|
||||||
@@ -89,7 +91,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
+ "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' "
|
+ "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' "
|
||||||
+ "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false "
|
+ "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false "
|
||||||
+ "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' "
|
+ "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' "
|
||||||
+ "left join m_appuser approver on approver.id = apr.checker_id ";
|
+ "left join m_appuser approver on approver.id = apr.checker_id "
|
||||||
|
// Lets the registry show that a correction is already awaiting a manager, instead of
|
||||||
|
// offering Edit again and letting a second request be raised over the same draft.
|
||||||
|
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' ";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
|
||||||
@@ -163,11 +168,13 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null;
|
final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null;
|
||||||
final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null;
|
final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null;
|
||||||
final String approverUsername = rs.getString("approver_username");
|
final String approverUsername = rs.getString("approver_username");
|
||||||
|
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
|
||||||
|
? rs.getLong("pending_amendment_request_id") : null;
|
||||||
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
|
||||||
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
|
||||||
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
|
||||||
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
|
||||||
approverUserId, approverUsername);
|
approverUserId, approverUsername, pendingAmendmentRequestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +238,12 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
|
|
||||||
private static final String GL_MAPPING_SELECT =
|
private static final String GL_MAPPING_SELECT =
|
||||||
"select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, "
|
"select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, "
|
||||||
+ "payable.name as dd_payable_gl_account_name, m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, "
|
+ "payable.name as dd_payable_gl_account_name, payable.gl_code as dd_payable_gl_code, "
|
||||||
+ "m.cash_gl_account_id, cash.name as cash_gl_account_name, m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, "
|
+ "m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, clearing.gl_code as cheque_clearing_gl_code, "
|
||||||
+ "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, "
|
+ "m.cash_gl_account_id, cash.name as cash_gl_account_name, cash.gl_code as cash_gl_code, "
|
||||||
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, "
|
+ "m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, charges.gl_code as charges_income_gl_code, "
|
||||||
|
+ "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, suspense.gl_code as cash_suspense_gl_code, "
|
||||||
|
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
|
||||||
+ "m.dd_limit_amount "
|
+ "m.dd_limit_amount "
|
||||||
+ "from m_sponsor_dd_gl_mapping m "
|
+ "from m_sponsor_dd_gl_mapping m "
|
||||||
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||||
@@ -268,22 +277,28 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
||||||
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
||||||
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
||||||
|
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
|
||||||
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
||||||
final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name");
|
final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name");
|
||||||
|
final String chequeClearingGlCode = rs.getString("cheque_clearing_gl_code");
|
||||||
final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null;
|
final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null;
|
||||||
final String cashGlAccountName = rs.getString("cash_gl_account_name");
|
final String cashGlAccountName = rs.getString("cash_gl_account_name");
|
||||||
|
final String cashGlCode = rs.getString("cash_gl_code");
|
||||||
final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null;
|
final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null;
|
||||||
final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name");
|
final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name");
|
||||||
|
final String chargesIncomeGlCode = rs.getString("charges_income_gl_code");
|
||||||
final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null;
|
final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null;
|
||||||
final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name");
|
final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name");
|
||||||
|
final String cashSuspenseGlCode = rs.getString("cash_suspense_gl_code");
|
||||||
final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage");
|
final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage");
|
||||||
final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null;
|
final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null;
|
||||||
final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name");
|
final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name");
|
||||||
|
final String gstPayableGlCode = rs.getString("gst_payable_gl_code");
|
||||||
final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount");
|
final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount");
|
||||||
return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName,
|
return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, ddPayableGlCode,
|
||||||
chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName,
|
chequeClearingGlAccountId, chequeClearingGlAccountName, chequeClearingGlCode, cashGlAccountId, cashGlAccountName, cashGlCode,
|
||||||
chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName,
|
chargesIncomeGlAccountId, chargesIncomeGlAccountName, chargesIncomeGlCode, cashSuspenseGlAccountId, cashSuspenseGlAccountName, cashSuspenseGlCode,
|
||||||
gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount);
|
gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, gstPayableGlCode, ddLimitAmount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +435,57 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String AMENDMENT_REQUEST_SELECT =
|
||||||
|
"select ar.id, ar.leaflet_id, leaf.dd_number, ar.beneficiary_name, ar.payable_at_branch, ar.payable_at_city, "
|
||||||
|
+ "ar.old_beneficiary_name, ar.old_payable_at_branch, ar.old_payable_at_city, "
|
||||||
|
+ "ar.reason, ar.status, ar.maker_id, maker.username as maker_username, ar.created_date, "
|
||||||
|
+ "ar.checker_id, checker.username as checker_username, ar.checker_date, ar.rejection_reason "
|
||||||
|
+ "from m_sponsor_dd_amendment_request ar "
|
||||||
|
+ "join m_sponsor_dd_leaflet leaf on leaf.id = ar.leaflet_id "
|
||||||
|
+ "join m_appuser maker on maker.id = ar.maker_id "
|
||||||
|
+ "left join m_appuser checker on checker.id = ar.checker_id ";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(final String status) {
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
final String sql = AMENDMENT_REQUEST_SELECT + "where ar.status = ? order by ar.id desc";
|
||||||
|
return this.jdbcTemplate.query(sql, new AmendmentRequestMapper(), status);
|
||||||
|
}
|
||||||
|
final String sql = AMENDMENT_REQUEST_SELECT + "order by ar.id desc";
|
||||||
|
return this.jdbcTemplate.query(sql, new AmendmentRequestMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AmendmentRequestMapper implements RowMapper<SponsorDDAmendmentRequestData> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SponsorDDAmendmentRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long id = rs.getLong("id");
|
||||||
|
final Long leafletId = rs.getLong("leaflet_id");
|
||||||
|
final String ddNumber = rs.getString("dd_number");
|
||||||
|
final String beneficiaryName = rs.getString("beneficiary_name");
|
||||||
|
final String payableAtBranch = rs.getString("payable_at_branch");
|
||||||
|
final String payableAtCity = rs.getString("payable_at_city");
|
||||||
|
final String oldBeneficiaryName = rs.getString("old_beneficiary_name");
|
||||||
|
final String oldPayableAtBranch = rs.getString("old_payable_at_branch");
|
||||||
|
final String oldPayableAtCity = rs.getString("old_payable_at_city");
|
||||||
|
final String reason = rs.getString("reason");
|
||||||
|
final String status = rs.getString("status");
|
||||||
|
final Long makerId = rs.getLong("maker_id");
|
||||||
|
final String makerUsername = rs.getString("maker_username");
|
||||||
|
final LocalDateTime createdDate = rs.getTimestamp("created_date") != null
|
||||||
|
? rs.getTimestamp("created_date").toLocalDateTime() : null;
|
||||||
|
final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null;
|
||||||
|
final String checkerUsername = rs.getString("checker_username");
|
||||||
|
final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null
|
||||||
|
? rs.getTimestamp("checker_date").toLocalDateTime() : null;
|
||||||
|
final String rejectionReason = rs.getString("rejection_reason");
|
||||||
|
|
||||||
|
return new SponsorDDAmendmentRequestData(id, leafletId, ddNumber, beneficiaryName, payableAtBranch, payableAtCity,
|
||||||
|
oldBeneficiaryName, oldPayableAtBranch, oldPayableAtCity, reason, status, makerId, makerUsername, createdDate,
|
||||||
|
checkerId, checkerUsername, checkerDate, rejectionReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<SponsorDDLeafletData> retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) {
|
public Collection<SponsorDDLeafletData> retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) {
|
||||||
final List<Object> params = new ArrayList<>();
|
final List<Object> params = new ArrayList<>();
|
||||||
@@ -440,6 +506,35 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
|||||||
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SponsorDDLeafletData retrieveNextAvailableLeaflet(final Long branchId, final Long sponsorBankId, final Long inventoryId) {
|
||||||
|
final StringBuilder sql = new StringBuilder(LEAFLET_SELECT);
|
||||||
|
sql.append("join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id ");
|
||||||
|
sql.append("where leaf.status = 'AVAILABLE' ");
|
||||||
|
sql.append("and leaf.id not in (select leaflet_id from m_sponsor_dd_issue_request where status = 'PENDING') ");
|
||||||
|
|
||||||
|
final List<Object> params = new java.util.ArrayList<>();
|
||||||
|
if (inventoryId != null) {
|
||||||
|
sql.append("and inv.id = ? ");
|
||||||
|
params.add(inventoryId);
|
||||||
|
}
|
||||||
|
if (branchId != null) {
|
||||||
|
sql.append("and inv.branch_id = ? ");
|
||||||
|
params.add(branchId);
|
||||||
|
}
|
||||||
|
if (sponsorBankId != null) {
|
||||||
|
sql.append("and inv.sponsor_bank_id = ? ");
|
||||||
|
params.add(sponsorBankId);
|
||||||
|
}
|
||||||
|
sql.append("order by inv.id asc, leaf.dd_number asc limit 1");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<SponsorDDCashierDepositOptionData> retrieveAvailableCashierDepositRequests() {
|
public Collection<SponsorDDCashierDepositOptionData> retrieveAvailableCashierDepositRequests() {
|
||||||
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
|
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
|
||||||
|
|||||||
+81
-2
@@ -434,7 +434,8 @@ public class VoucherVerificationApiResource {
|
|||||||
@GET
|
@GET
|
||||||
@Path("report")
|
@Path("report")
|
||||||
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) {
|
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
|
||||||
|
@QueryParam("cashOnly") final Boolean cashOnly) {
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
final boolean seesEveryone = isCheckerRole(currentUser);
|
final boolean seesEveryone = isCheckerRole(currentUser);
|
||||||
@@ -460,7 +461,8 @@ public class VoucherVerificationApiResource {
|
|||||||
ver.status AS verification_status,
|
ver.status AS verification_status,
|
||||||
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
||||||
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
||||||
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count
|
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
|
||||||
|
cash.moved AS is_cash
|
||||||
FROM m_voucher v
|
FROM m_voucher v
|
||||||
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
@@ -472,6 +474,52 @@ public class VoucherVerificationApiResource {
|
|||||||
SELECT party_name, account_no FROM m_voucher_line
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||||
ORDER BY line_no LIMIT 1) cr ON TRUE
|
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||||
|
-- A Demand Draft counts its notes at submission and keys them by the request id,
|
||||||
|
-- then re-keys them to the resulting journal entry once the draft is approved,
|
||||||
|
-- while the voucher keeps pointing at the request. Neither id alone finds the
|
||||||
|
-- notes for the whole life of the draft, so the request is joined to supply the
|
||||||
|
-- second one.
|
||||||
|
LEFT JOIN m_sponsor_dd_issue_request dd
|
||||||
|
ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST' AND dd.id = v.reference_id
|
||||||
|
-- Whether notes and coins were counted against this voucher. A denomination row
|
||||||
|
-- is written only when money was physically counted, so its presence proves cash;
|
||||||
|
-- its absence proves nothing, because several registers that genuinely pay out
|
||||||
|
-- cash record no breakdown at all (the clerk disbursement register and the loan
|
||||||
|
-- module write none, and a clerk withdrawal has none until it is disbursed).
|
||||||
|
--
|
||||||
|
-- Each register keys these rows by an id of its own, all sharing one txn_id
|
||||||
|
-- column, so every branch is tied to the reference type that wrote it: an
|
||||||
|
-- unqualified match would let a deposit request borrow the notes counted for an
|
||||||
|
-- unrelated savings transaction of the same id.
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRUE AS moved FROM m_savings_txn_denomination d
|
||||||
|
WHERE d.txn_id = v.savings_txn_id
|
||||||
|
OR (v.reference_type IN ('SAVINGS_TRANSACTION', 'CASHIER_DEPOSIT_REQUEST',
|
||||||
|
'CLERK_WITHDRAWAL_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST')
|
||||||
|
AND d.txn_id = v.reference_id)
|
||||||
|
OR d.txn_id = dd.resulting_journal_entry_id
|
||||||
|
LIMIT 1) cash ON TRUE
|
||||||
|
-- Whether either side of the voucher is the till itself.
|
||||||
|
--
|
||||||
|
-- This is what separates money crossing the counter from a book entry between two
|
||||||
|
-- ledger accounts. A DD shows why it is needed: taking the purchaser's notes debits
|
||||||
|
-- Cash at Vault, so it is a real receipt, but approving the draft afterwards only
|
||||||
|
-- moves the amount from suspense to DD Payable -- no cash, no cashier, yet it
|
||||||
|
-- carries a DR number and was landing on the payment report as cash paid out.
|
||||||
|
--
|
||||||
|
-- Two shapes have to be recognised. Registers that know their GL write a real
|
||||||
|
-- gl_account_id, matched against the cash-at-teller and cash-at-vault financial
|
||||||
|
-- activities (102 and 101). The counter registers do not carry a GL id for the
|
||||||
|
-- drawer at all and write the line named 'Cash in Hand' with a null id, so that
|
||||||
|
-- name is accepted as the marker it is used as.
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRUE AS touched FROM m_voucher_line cl
|
||||||
|
WHERE cl.voucher_no = v.voucher_no
|
||||||
|
AND (cl.gl_account_id IN (
|
||||||
|
SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa
|
||||||
|
WHERE fa.financial_activity_type IN (101, 102))
|
||||||
|
OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand'))
|
||||||
|
LIMIT 1) till ON TRUE
|
||||||
WHERE v.created_at >= ? AND v.created_at < ?
|
WHERE v.created_at >= ? AND v.created_at < ?
|
||||||
""");
|
""");
|
||||||
|
|
||||||
@@ -489,6 +537,32 @@ public class VoucherVerificationApiResource {
|
|||||||
sql.append(" AND v.voucher_type = ?");
|
sql.append(" AND v.voucher_type = ?");
|
||||||
args.add(voucherType.trim());
|
args.add(voucherType.trim());
|
||||||
}
|
}
|
||||||
|
// A cashier's register is cash: what came over the counter in notes and coins.
|
||||||
|
//
|
||||||
|
// The test is whether a side of the voucher is the till (the `till` join above). That is
|
||||||
|
// what tells a receipt apart from a book entry between two ledger accounts, and it is the
|
||||||
|
// only test that gets a Demand Draft right: the same draft raises a genuine cash receipt
|
||||||
|
// when the notes are taken and a pure suspense-to-payable transfer when it is approved,
|
||||||
|
// both under the same reference type, and only the ledger accounts distinguish them.
|
||||||
|
//
|
||||||
|
// A voucher with no lines at all is kept. The registers attach their sides at approval, so
|
||||||
|
// a voucher raised and not yet actioned has none, and dropping those would hide real money
|
||||||
|
// from the report during the very window in which someone is looking for it. Under-reporting
|
||||||
|
// cash is the worse failure: a book entry on the register is visible and can be recognised,
|
||||||
|
// whereas cash that never appears surfaces only as an unexplained difference at the close.
|
||||||
|
//
|
||||||
|
// The type and reference exclusions below are kept for entries that carry a cash line yet
|
||||||
|
// still are not counter work -- interest posted into an account, a cheque settling between
|
||||||
|
// banks, a fee debited to a member, an RTGS leaving the bank's own cash account.
|
||||||
|
final boolean restrictToCash = Boolean.TRUE.equals(cashOnly);
|
||||||
|
if (restrictToCash) {
|
||||||
|
sql.append(" AND (till.touched IS TRUE OR NOT EXISTS ("
|
||||||
|
+ "SELECT 1 FROM m_voucher_line nl WHERE nl.voucher_no = v.voucher_no))");
|
||||||
|
sql.append(" AND v.voucher_type <> 'INTEREST_POSTING'");
|
||||||
|
sql.append(" AND v.voucher_no NOT LIKE 'JV/%'");
|
||||||
|
sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN ("
|
||||||
|
+ "'CLEARING_POSTING', 'CHEQUEBOOK_CHARGE', 'BANK_TRANSFER_LOG'))");
|
||||||
|
}
|
||||||
sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC");
|
sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC");
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||||
@@ -518,6 +592,10 @@ public class VoucherVerificationApiResource {
|
|||||||
// so the row says how much it is hiding rather than misrepresenting the voucher.
|
// so the row says how much it is hiding rather than misrepresenting the voucher.
|
||||||
final int lineCount = ((Number) r.get("line_count")).intValue();
|
final int lineCount = ((Number) r.get("line_count")).intValue();
|
||||||
m.put("extraLines", Math.max(0, lineCount - 2));
|
m.put("extraLines", Math.max(0, lineCount - 2));
|
||||||
|
// True when notes and coins were counted against this voucher. False means unconfirmed,
|
||||||
|
// not non-cash: the registers that record no breakdown still pay real money out. The
|
||||||
|
// screen marks these rather than dropping them.
|
||||||
|
m.put("denominationCounted", Boolean.TRUE.equals(r.get("is_cash")));
|
||||||
out.add(m);
|
out.add(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +606,7 @@ public class VoucherVerificationApiResource {
|
|||||||
// Tells the screen whether to offer the user filter at all, rather than having it guess
|
// Tells the screen whether to offer the user filter at all, rather than having it guess
|
||||||
// from the caller's roles a second time.
|
// from the caller's roles a second time.
|
||||||
res.put("canSeeAllUsers", seesEveryone);
|
res.put("canSeeAllUsers", seesEveryone);
|
||||||
|
res.put("cashOnly", restrictToCash);
|
||||||
res.put("vouchers", out);
|
res.put("vouchers", out);
|
||||||
res.put("count", out.size());
|
res.put("count", out.size());
|
||||||
res.put("totalAmount", total);
|
res.put("totalAmount", total);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
or more contributor license agreements. See the NOTICE file
|
or more contributor license agreements. See the NOTICE file
|
||||||
distributed with this work for additional information
|
distributed with this work for additional information
|
||||||
@@ -8,16 +7,13 @@
|
|||||||
to you under the Apache License, Version 2.0 (the
|
to you under the Apache License, Version 2.0 (the
|
||||||
"License"); you may not use this file except in compliance
|
"License"); you may not use this file except in compliance
|
||||||
with the License. You may obtain a copy of the License at
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
Unless required by applicable law or agreed to in writing,
|
||||||
software distributed under the License is distributed on an
|
software distributed under the License is distributed on an
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
KIND, either express or implied. See the License for the
|
KIND, either express or implied. See the License for the
|
||||||
specific language governing permissions and limitations
|
specific language governing permissions and limitations
|
||||||
under the License.
|
under the License.
|
||||||
|
|
||||||
-->
|
-->
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
@@ -586,4 +582,17 @@
|
|||||||
<include file="parts/0474_enable_fixed_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0474_enable_fixed_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0475_enable_admin_user_maker_checker.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0475_enable_admin_user_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0476_add_user_pending_request_and_enable_charge_mc.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0476_add_user_pending_request_and_enable_charge_mc.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0520_add_kyc_expiry_restrictions_job.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0521_rbi_dormancy_defaults.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0522_create_deaf_remittance.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0523_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0524_rbi_dormancy_periods_all_products.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0525_savings_daily_interest_job.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0526_savings_interest_summary_view.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0527_savings_daily_interest_one_row_per_account.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0528_fix_escheat_liability_mapping.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0530_drop_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0531_disable_stock_savings_interest_job.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0532_sponsor_dd_amendment_request.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+5
@@ -108,6 +108,11 @@
|
|||||||
next_posting_date is deliberately left null: the first run works it out from the posting
|
next_posting_date is deliberately left null: the first run works it out from the posting
|
||||||
period and the financial year start, and fills it in. Seeding a guess here would put a
|
period and the financial year start, and fills it in. Seeding a guess here would put a
|
||||||
wrong date in front of somebody before the job has ever run.
|
wrong date in front of somebody before the job has ever run.
|
||||||
|
|
||||||
|
NOTE: this seeds only the products that existed when it ran. A product created afterwards is
|
||||||
|
enrolled by SavingsDailyInterestOrchestrator.enrolNewProducts() on the next run, and 0525
|
||||||
|
catches up any that were left behind in between. Do not edit this changeset to widen it —
|
||||||
|
it has already run, and changing it breaks the checksum.
|
||||||
-->
|
-->
|
||||||
<changeSet id="0518_seed_savings_posting_schedule_rows" author="CBS">
|
<changeSet id="0518_seed_savings_posting_schedule_rows" author="CBS">
|
||||||
<sql>
|
<sql>
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Registers the nightly pass that applies the RBI re-KYC restriction ladder: debits stop 90
|
||||||
|
days past the due date, the account goes inoperative at 180, and everything is released once
|
||||||
|
the KYC is renewed. Scheduled just after midnight so the day opens with restrictions correct.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<insert tableName="job">
|
||||||
|
<column name="name" value="Apply KYC Expiry Restrictions"/>
|
||||||
|
<column name="display_name" value="Apply KYC Expiry Restrictions"/>
|
||||||
|
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
|
||||||
|
<column name="short_name" value="KYC_EXPR"/>
|
||||||
|
<column name="cron_expression" value="0 10 0 1/1 * ? *"/>
|
||||||
|
<column name="create_time" valueDate="${current_datetime}"/>
|
||||||
|
<column name="task_priority" valueNumeric="5"/>
|
||||||
|
<column name="group_name"/>
|
||||||
|
<column name="previous_run_start_time"/>
|
||||||
|
<column name="job_key" value="Apply KYC Expiry RestrictionsJobDetail1 _ DEFAULT"/>
|
||||||
|
<column name="initializing_errorlog"/>
|
||||||
|
<column name="is_active" valueBoolean="true"/>
|
||||||
|
<column name="currently_running" valueBoolean="false"/>
|
||||||
|
<column name="updates_allowed" valueBoolean="true"/>
|
||||||
|
<column name="scheduler_group" valueNumeric="1"/>
|
||||||
|
<column name="is_misfired" valueBoolean="false"/>
|
||||||
|
<column name="node_id" valueNumeric="1"/>
|
||||||
|
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Turns dormancy tracking on with the periods RBI works to.
|
||||||
|
|
||||||
|
The columns ship as NULL, and the nightly "Update Savings Dormant Accounts" job joins on
|
||||||
|
is_dormancy_tracking_active = true, so until a bank fills these in no account ever becomes
|
||||||
|
inoperative — the tracking is built but idle. These are the RBI periods, counted from the
|
||||||
|
last customer-induced transaction:
|
||||||
|
|
||||||
|
365 days -> inactive (an internal flag; nothing is restricted)
|
||||||
|
730 days -> dormant (2 years; debits stop, reactivation needs the branch)
|
||||||
|
3650 days -> escheat (10 years; the balance is due to DEAF)
|
||||||
|
|
||||||
|
Only savings products are touched (deposit_type_enum = 100). A term or recurring deposit is
|
||||||
|
not expected to see customer transactions between its opening and its maturity, so treating
|
||||||
|
that silence as dormancy would put every healthy FD into an inoperative state.
|
||||||
|
|
||||||
|
Products where someone has already set their own periods are left exactly as they are: a
|
||||||
|
configured value is a decision, and this migration is a default, not a correction.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<update tableName="m_savings_product">
|
||||||
|
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
|
||||||
|
<column name="days_to_inactive" valueNumeric="365"/>
|
||||||
|
<column name="days_to_dormancy" valueNumeric="730"/>
|
||||||
|
<column name="days_to_escheat" valueNumeric="3650"/>
|
||||||
|
<where>deposit_type_enum = 100 AND (is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false)
|
||||||
|
AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The same periods for current accounts, which carry their own product table and are equally
|
||||||
|
subject to the inoperative rules.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="2">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_current_product"/>
|
||||||
|
</preConditions>
|
||||||
|
<update tableName="m_current_product">
|
||||||
|
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
|
||||||
|
<column name="days_to_inactive" valueNumeric="365"/>
|
||||||
|
<column name="days_to_dormancy" valueNumeric="730"/>
|
||||||
|
<column name="days_to_escheat" valueNumeric="3650"/>
|
||||||
|
<where>(is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false)
|
||||||
|
AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Records that an escheated balance actually reached RBI's DEAF.
|
||||||
|
|
||||||
|
Escheat and remittance are separate events: the nightly job closes the account and removes the
|
||||||
|
balance, but sending that money to RBI happens outside this system. Without this table the
|
||||||
|
escheat report cannot tell a remitted balance from one still sitting with the bank, and money
|
||||||
|
taken out of customers' accounts can drift unaccounted for.
|
||||||
|
|
||||||
|
One row per savings account — a balance goes to the fund once. refunded_on covers the case
|
||||||
|
where the customer later claims it and the bank recovers the amount from the fund.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<createTable tableName="m_deaf_remittance">
|
||||||
|
<column autoIncrement="true" name="id" type="BIGINT">
|
||||||
|
<constraints primaryKey="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_account_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" unique="true" uniqueConstraintName="uq_deaf_remittance_savings_account"/>
|
||||||
|
</column>
|
||||||
|
<column name="client_id" type="BIGINT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="amount" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="escheated_on" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="remitted_on" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="deaf_reference" type="VARCHAR(100)"/>
|
||||||
|
<column name="refunded_on" type="DATE"/>
|
||||||
|
<column name="remarks" type="VARCHAR(500)"/>
|
||||||
|
<column name="recorded_by_user_id" type="BIGINT"/>
|
||||||
|
<column name="recorded_on" type="DATETIME">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseColumnNames="savings_account_id" baseTableName="m_deaf_remittance"
|
||||||
|
constraintName="fk_deaf_remittance_savings_account"
|
||||||
|
referencedColumnNames="id" referencedTableName="m_savings_account"/>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseColumnNames="client_id" baseTableName="m_deaf_remittance"
|
||||||
|
constraintName="fk_deaf_remittance_client"
|
||||||
|
referencedColumnNames="id" referencedTableName="m_client"/>
|
||||||
|
|
||||||
|
<createIndex indexName="idx_deaf_remittance_remitted_on" tableName="m_deaf_remittance">
|
||||||
|
<column name="remitted_on"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Marks an account as receiving government benefit credits, which exempts it from dormancy.
|
||||||
|
|
||||||
|
RBI directs that an account credited with DBT, pension, scholarship or similar benefit
|
||||||
|
transfers is not to be classified inoperative for want of customer transactions. The reason is
|
||||||
|
practical: the holder of a pension or subsidy account often does nothing but let the money
|
||||||
|
arrive, and freezing debits on such an account stops the very payment it exists to receive.
|
||||||
|
|
||||||
|
The flag is set on the account rather than inferred from transaction narrations. Narrations
|
||||||
|
vary by sponsor, channel and file format, so matching on them would exempt accounts by
|
||||||
|
accident and miss ones that qualify. A branch marking the account is a decision on record.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<addColumn tableName="m_savings_account">
|
||||||
|
<column name="is_dbt_beneficiary" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Releases any dormancy state already applied to an account since marked as a DBT beneficiary.
|
||||||
|
Only the dormancy sub-statuses are cleared (100 inactive, 200 dormant) — an escheated account
|
||||||
|
has had its balance removed and cannot be undone here, and a manual block is somebody's
|
||||||
|
deliberate act.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="2">
|
||||||
|
<update tableName="m_savings_account">
|
||||||
|
<column name="sub_status_enum" valueNumeric="0"/>
|
||||||
|
<where>is_dbt_beneficiary = true AND sub_status_enum IN (100, 200)</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Brings every product's dormancy periods onto the RBI schedule.
|
||||||
|
|
||||||
|
0521 set these only where all three columns were still NULL, on the reasoning that a
|
||||||
|
configured value was somebody's decision. That left the products carrying short trial values
|
||||||
|
— a few weeks rather than a year — which are not decisions but leftovers, and they make
|
||||||
|
healthy accounts show as overdue for inactivity within a month of their last transaction.
|
||||||
|
|
||||||
|
RBI counts from the last customer-induced transaction: 365 days to inactive, 730 to
|
||||||
|
inoperative, 3650 to DEAF. Anything materially shorter is not a policy choice a bank could
|
||||||
|
defend, so every savings and current product is brought to these periods.
|
||||||
|
|
||||||
|
Term and recurring deposits keep whatever they have: an FD sees no customer transaction
|
||||||
|
between opening and maturity, so treating that silence as dormancy would put every healthy
|
||||||
|
one into an inoperative state.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<update tableName="m_savings_product">
|
||||||
|
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
|
||||||
|
<column name="days_to_inactive" valueNumeric="365"/>
|
||||||
|
<column name="days_to_dormancy" valueNumeric="730"/>
|
||||||
|
<column name="days_to_escheat" valueNumeric="3650"/>
|
||||||
|
<where>deposit_type_enum = 100</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="2">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_current_product"/>
|
||||||
|
</preConditions>
|
||||||
|
<update tableName="m_current_product">
|
||||||
|
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
|
||||||
|
<column name="days_to_inactive" valueNumeric="365"/>
|
||||||
|
<column name="days_to_dormancy" valueNumeric="730"/>
|
||||||
|
<column name="days_to_escheat" valueNumeric="3650"/>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Clears dormancy that the short periods had already applied. An account marked inactive or
|
||||||
|
dormant because a three-week threshold elapsed is not dormant under the RBI periods, and
|
||||||
|
leaving the sub-status set would keep it restricted on a rule that no longer exists.
|
||||||
|
|
||||||
|
Escheat is not reversed — that balance has left the account — and a manual block stays,
|
||||||
|
since neither was applied by the dormancy job.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="3">
|
||||||
|
<update tableName="m_savings_account">
|
||||||
|
<column name="sub_status_enum" valueNumeric="0"/>
|
||||||
|
<where>sub_status_enum IN (100, 200)</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Registers the job that runs the daily-interest ledger.
|
||||||
|
|
||||||
|
The orchestrator was only reachable through its API, so accrual ran for as long as somebody
|
||||||
|
kept calling it and posting happened only when somebody remembered. Interest a bank owes its
|
||||||
|
depositors cannot depend on that.
|
||||||
|
|
||||||
|
Scheduled at 00:30 to match the run_at_time the posting schedule already carries, and after
|
||||||
|
the dormancy and KYC jobs so the day's account states are settled before interest is paid.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<insert tableName="job">
|
||||||
|
<column name="name" value="Savings Daily Interest Accrual And Posting"/>
|
||||||
|
<column name="display_name" value="Savings Daily Interest Accrual And Posting"/>
|
||||||
|
<column name="short_name" value="SA_DINT"/>
|
||||||
|
<column name="cron_expression" value="0 30 0 1/1 * ? *"/>
|
||||||
|
<column name="create_time" valueDate="${current_datetime}"/>
|
||||||
|
<column name="task_priority" valueNumeric="5"/>
|
||||||
|
<column name="group_name"/>
|
||||||
|
<column name="previous_run_start_time"/>
|
||||||
|
<column name="job_key" value="Savings Daily Interest Accrual And PostingJobDetail1 _ DEFAULT"/>
|
||||||
|
<column name="initializing_errorlog"/>
|
||||||
|
<column name="is_active" valueBoolean="true"/>
|
||||||
|
<column name="currently_running" valueBoolean="false"/>
|
||||||
|
<column name="updates_allowed" valueBoolean="true"/>
|
||||||
|
<column name="scheduler_group" valueNumeric="1"/>
|
||||||
|
<column name="is_misfired" valueBoolean="false"/>
|
||||||
|
<column name="node_id" valueNumeric="1"/>
|
||||||
|
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Enrols the savings products created since 0518 seeded this table. The orchestrator now enrols
|
||||||
|
new products on every run, but doing it here as well means the products already sitting with
|
||||||
|
unposted accruals are picked up on the first run rather than the second.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="2">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_savings_interest_posting_schedule
|
||||||
|
(savings_product_id, posting_period_enum, run_at_time, is_active)
|
||||||
|
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
|
||||||
|
FROM m_savings_product sp
|
||||||
|
WHERE sp.deposit_type_enum = 100
|
||||||
|
AND sp.interest_posting_period_enum IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
|
||||||
|
WHERE s.savings_product_id = sp.id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One row per savings account, carrying everything the daily ledger knows about it.
|
||||||
|
|
||||||
|
m_savings_daily_interest is a day-by-day record and has to stay that way: interest is worked
|
||||||
|
out on the balance each day actually carried, an audit asks what a given day earned, and a
|
||||||
|
reversed backdated entry means every day after it is recomputed. None of that survives being
|
||||||
|
collapsed into a single stored row.
|
||||||
|
|
||||||
|
What a reader usually wants, though, is the current position — this account, this cycle, this
|
||||||
|
much accrued — without reading thirty rows to find it. That is what this view is: the latest
|
||||||
|
day's figures per account, with the cycle totals and the lifetime posted total alongside, so
|
||||||
|
a screen or a report can select one row per account and be done.
|
||||||
|
|
||||||
|
The cumulative figure resets on its own: cycle_cumulative_interest counts from the cycle's
|
||||||
|
start, so the day after a posting it begins again at that day's interest.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<createView viewName="v_savings_interest_summary" replaceIfExists="true">
|
||||||
|
SELECT
|
||||||
|
d.savings_account_id AS savings_account_id,
|
||||||
|
sa.account_no AS account_no,
|
||||||
|
sa.client_id AS client_id,
|
||||||
|
c.display_name AS client_name,
|
||||||
|
o.name AS office_name,
|
||||||
|
d.savings_product_id AS savings_product_id,
|
||||||
|
sp.name AS product_name,
|
||||||
|
sa.currency_code AS currency_code,
|
||||||
|
|
||||||
|
d.balance_date AS as_on_date,
|
||||||
|
d.opening_balance AS opening_balance,
|
||||||
|
d.closing_balance AS closing_balance,
|
||||||
|
d.daily_interest AS interest_today,
|
||||||
|
|
||||||
|
d.cycle_number AS cycle_number,
|
||||||
|
d.cycle_start_date AS cycle_start_date,
|
||||||
|
d.cycle_end_date AS cycle_end_date,
|
||||||
|
d.cycle_cumulative_interest AS interest_this_cycle,
|
||||||
|
(d.cycle_end_date - d.balance_date) AS days_to_posting,
|
||||||
|
d.is_posting_day AS is_posting_day,
|
||||||
|
d.posting_date AS posting_date,
|
||||||
|
|
||||||
|
d.interest_rate AS interest_rate,
|
||||||
|
d.days_in_year AS days_in_year,
|
||||||
|
d.compounding_period_enum AS compounding_period_enum,
|
||||||
|
d.posting_period_enum AS posting_period_enum,
|
||||||
|
d.calculation_type_enum AS calculation_type_enum,
|
||||||
|
d.min_balance_for_interest AS min_balance_for_interest,
|
||||||
|
d.below_min_balance AS below_min_balance,
|
||||||
|
|
||||||
|
COALESCE(t.posted_total, 0) AS interest_posted_total,
|
||||||
|
t.last_posted_on AS last_posted_on,
|
||||||
|
COALESCE(t.days_accrued, 0) AS days_accrued,
|
||||||
|
d.last_recomputed_on AS last_recomputed_on
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
JOIN m_savings_product sp ON sp.id = d.savings_product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = c.office_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT savings_account_id,
|
||||||
|
SUM(COALESCE(posted_amount, 0)) AS posted_total,
|
||||||
|
MAX(posting_date) FILTER (WHERE savings_transaction_id IS NOT NULL) AS last_posted_on,
|
||||||
|
COUNT(*) AS days_accrued
|
||||||
|
FROM m_savings_daily_interest
|
||||||
|
GROUP BY savings_account_id
|
||||||
|
) t ON t.savings_account_id = d.savings_account_id
|
||||||
|
WHERE d.balance_date = (
|
||||||
|
SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = d.savings_account_id
|
||||||
|
)
|
||||||
|
</createView>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Makes the ledger hold one row per savings account instead of one per account per day.
|
||||||
|
|
||||||
|
The running total each row carries (cycle_cumulative_interest) is what the posting reads, and
|
||||||
|
it is complete on its own: the day's interest is added to it and the row is rewritten, so a
|
||||||
|
single row states where the account stands without the days behind it.
|
||||||
|
|
||||||
|
What this gives up is worth stating, because it cannot be recovered later:
|
||||||
|
|
||||||
|
- the balance each day carried. Interest is worked out on it, so a recomputation after the
|
||||||
|
fact can only use today's balance for every day, which is wrong wherever the balance moved.
|
||||||
|
- the answer to "what did this account earn on 15 August", which an audit can ask.
|
||||||
|
- repairing a reversed backdated transaction. Rebuilding from the day of the reversal needs
|
||||||
|
each day's balance, and after this only the latest is held.
|
||||||
|
|
||||||
|
The rows already accrued are kept in m_savings_daily_interest_history_backup so what has been
|
||||||
|
earned so far is still answerable. Nothing writes to that table; it is a record of what the
|
||||||
|
ledger held before this change.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One row per account: the uniqueness that used to include the date now excludes it.
|
||||||
|
|
||||||
|
Dropped as a constraint rather than an index — it was created as a unique constraint, and the
|
||||||
|
index behind it cannot be dropped on its own.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<indexExists indexName="uq_savings_daily_interest_account_date"/>
|
||||||
|
</preConditions>
|
||||||
|
<dropUniqueConstraint tableName="m_savings_daily_interest"
|
||||||
|
constraintName="uq_savings_daily_interest_account_date"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Collapse anything still holding more than one row per account, so the new constraint can be
|
||||||
|
created. The latest day is kept: it is the one carrying the current cycle's running total.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="2">
|
||||||
|
<sql>
|
||||||
|
DELETE FROM m_savings_daily_interest d
|
||||||
|
WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
|
||||||
|
WHERE x.savings_account_id = d.savings_account_id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="3">
|
||||||
|
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
|
||||||
|
constraintName="uq_savings_daily_interest_account"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The product/date index existed to find a product's rows for one date. With one row per account
|
||||||
|
the date adds nothing to it, and the posting query now looks up by product alone.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="4">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<indexExists indexName="idx_sdi_product_date"/>
|
||||||
|
</preConditions>
|
||||||
|
<dropIndex indexName="idx_sdi_product_date" tableName="m_savings_daily_interest"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="5">
|
||||||
|
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
|
||||||
|
<column name="savings_product_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Points every product's escheat mapping at the escheat liability account.
|
||||||
|
|
||||||
|
Escheat moves a balance unclaimed for ten years out of the customer's account, and it is owed
|
||||||
|
to RBI's DEAF until remitted. Products were carrying deposit and tax accounts here instead —
|
||||||
|
GST Payable, Savings Individual, Current Account — so an escheated balance would land in a
|
||||||
|
liability that belongs to something else entirely, and the amount owed to the fund could not
|
||||||
|
be read off the ledger at all.
|
||||||
|
|
||||||
|
200904 Escheat Liability Account already exists and is the account meant for this. Only the
|
||||||
|
escheat mapping (financial_account_type 14) is touched, and only where it points somewhere
|
||||||
|
other than that account.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="1">
|
||||||
|
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200904'
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
UPDATE acc_product_mapping
|
||||||
|
SET gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = '200904')
|
||||||
|
WHERE financial_account_type = 14
|
||||||
|
AND gl_account_id <> (SELECT id FROM acc_gl_account WHERE gl_code = '200904')
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet author="cbs" id="0529-1-add-denomination-data-to-sponsor-dd-issue-request">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_sponsor_dd_issue_request" />
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_sponsor_dd_issue_request" columnName="denomination_data" />
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_sponsor_dd_issue_request">
|
||||||
|
<column name="denomination_data" type="TEXT">
|
||||||
|
<constraints nullable="true" />
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Removes the per-account government-benefit exemption added in 0523.
|
||||||
|
|
||||||
|
The exemption marked an account as receiving DBT, pension or scholarship credits and kept it
|
||||||
|
out of the dormancy ladder and the KYC-expiry restrictions. It has been withdrawn: the flag,
|
||||||
|
the endpoint that set it, and the query filters that honoured it are all gone, so the column
|
||||||
|
would only sit there unread and imply a protection nothing applies.
|
||||||
|
|
||||||
|
0523 itself is left alone — it has already run, and editing an applied changeset breaks its
|
||||||
|
checksum. This undoes it forward instead.
|
||||||
|
|
||||||
|
Nothing is lost with the column: no account in this tenant was ever flagged. Should the
|
||||||
|
exemption be wanted again, it is the flag that has to come back, not data.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<columnExists tableName="m_savings_account" columnName="is_dbt_beneficiary"/>
|
||||||
|
</preConditions>
|
||||||
|
<dropColumn tableName="m_savings_account" columnName="is_dbt_beneficiary"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Stops the platform's own savings interest job, leaving the daily-interest ledger as the only
|
||||||
|
thing that pays savings interest.
|
||||||
|
|
||||||
|
Both were running nightly, minutes apart, and they do not agree. The stock job pays a day at
|
||||||
|
a time and takes no account of the product's posting period: a product configured quarterly
|
||||||
|
was paid on 135 separate dates, which is neither quarterly nor reconcilable against one. The
|
||||||
|
ledger job posts on the cycle the product actually declares.
|
||||||
|
|
||||||
|
Two jobs both crediting the same accounts is the worse problem: interest paid twice for the
|
||||||
|
same period, with nothing in either to notice the other had already paid it.
|
||||||
|
|
||||||
|
Interest keeps being paid — "Savings Daily Interest Accrual And Posting" (0525) does it, on
|
||||||
|
each product's own rhythm. Re-enabling this one means going back to postings that ignore the
|
||||||
|
product's configuration.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<update tableName="job">
|
||||||
|
<column name="is_active" valueBoolean="false"/>
|
||||||
|
<where>name = 'Post Interest For Savings'</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Amending an already-issued Demand Draft.
|
||||||
|
|
||||||
|
A beneficiary's name or the branch and city a draft is payable at can be written wrong, and the
|
||||||
|
instrument is already printed and in the purchaser's hands by the time anyone notices. Changing
|
||||||
|
it is not a clerical edit: the draft is a payment instruction the sponsor bank will honour, so
|
||||||
|
the correction goes through the same maker-checker step an issue or a reprint does.
|
||||||
|
|
||||||
|
Only those three fields. Face value, purchaser and funding are never amended — a draft drawn for
|
||||||
|
the wrong amount or against the wrong account is cancelled and re-issued, because the money has
|
||||||
|
already moved and an edit would leave the ledger disagreeing with the instrument.
|
||||||
|
|
||||||
|
The old values are kept on the request so the approval queue can show the checker exactly what
|
||||||
|
is being changed, and so the record survives even after the leaflet itself is updated.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet author="cbs" id="0532-1-create-sponsor-dd-amendment-request">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not>
|
||||||
|
<tableExists tableName="m_sponsor_dd_amendment_request" />
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_sponsor_dd_amendment_request">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false" />
|
||||||
|
</column>
|
||||||
|
<column name="leaflet_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" />
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- What it is being changed to. -->
|
||||||
|
<column name="beneficiary_name" type="VARCHAR(200)" />
|
||||||
|
<column name="payable_at_branch" type="VARCHAR(200)" />
|
||||||
|
<column name="payable_at_city" type="VARCHAR(200)" />
|
||||||
|
|
||||||
|
<!-- What it was, so the checker sees the change rather than only the result. -->
|
||||||
|
<column name="old_beneficiary_name" type="VARCHAR(200)" />
|
||||||
|
<column name="old_payable_at_branch" type="VARCHAR(200)" />
|
||||||
|
<column name="old_payable_at_city" type="VARCHAR(200)" />
|
||||||
|
|
||||||
|
<column name="reason" type="VARCHAR(1000)" />
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||||
|
<constraints nullable="false" />
|
||||||
|
</column>
|
||||||
|
<column name="maker_id" type="BIGINT" />
|
||||||
|
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP" />
|
||||||
|
<column name="checker_id" type="BIGINT" />
|
||||||
|
<column name="checker_date" type="TIMESTAMP" />
|
||||||
|
<column name="rejection_reason" type="VARCHAR(1000)" />
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createIndex tableName="m_sponsor_dd_amendment_request" indexName="idx_dd_amendment_leaflet">
|
||||||
|
<column name="leaflet_id" />
|
||||||
|
</createIndex>
|
||||||
|
<createIndex tableName="m_sponsor_dd_amendment_request" indexName="idx_dd_amendment_status">
|
||||||
|
<column name="status" />
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+8
-8
@@ -56,8 +56,9 @@ public class SavingsProductAccountingDataValidator {
|
|||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue())
|
||||||
.value(savingsReferenceAccountId).notNull().integerGreaterThanZero();
|
.value(savingsReferenceAccountId).notNull().integerGreaterThanZero();
|
||||||
}
|
}
|
||||||
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)
|
// Transfers In Suspense is not offered by the savings product screen, so it is validated only
|
||||||
|| ignoreExistenceValidation) {
|
// when actually supplied — ignoreExistenceValidation must not make it mandatory on create.
|
||||||
|
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
||||||
final Long transfersInSuspenseAccountId = fromApiJsonHelper
|
final Long transfersInSuspenseAccountId = fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
@@ -85,22 +86,21 @@ public class SavingsProductAccountingDataValidator {
|
|||||||
.value(incomeFromPenaltyId).notNull().integerGreaterThanZero();
|
.value(incomeFromPenaltyId).notNull().integerGreaterThanZero();
|
||||||
}
|
}
|
||||||
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||||
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)
|
// Overdraft Control / Income From Interest / Write Off are likewise not offered by the
|
||||||
|| ignoreExistenceValidation) {
|
// savings product screen, so they too are validated only when actually supplied.
|
||||||
|
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)) {
|
||||||
final Long overdraftControlAccountId = fromApiJsonHelper
|
final Long overdraftControlAccountId = fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||||
.value(overdraftControlAccountId).notNull().integerGreaterThanZero();
|
.value(overdraftControlAccountId).notNull().integerGreaterThanZero();
|
||||||
}
|
}
|
||||||
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)
|
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
|
||||||
|| ignoreExistenceValidation) {
|
|
||||||
final Long incomeFromInterest = fromApiJsonHelper
|
final Long incomeFromInterest = fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
||||||
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
||||||
}
|
}
|
||||||
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)
|
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
||||||
|| ignoreExistenceValidation) {
|
|
||||||
final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff)
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that an escheated balance was actually remitted to RBI's Depositor Education and Awareness
|
||||||
|
* Fund.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Escheat and remittance are two separate events. The nightly job closes the account and takes the
|
||||||
|
* balance out; sending that money to RBI happens outside this system, on RBI's own schedule. Until
|
||||||
|
* a row exists here the balance is out of the customer's account but not accounted for as having
|
||||||
|
* reached the fund, and the escheat report says exactly that rather than assuming it was sent.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A customer can claim an unclaimed balance at any time even after remittance: the bank pays them
|
||||||
|
* and recovers the amount from the fund. {@code refundedOn} records that, so a remittance that has
|
||||||
|
* since been drawn back is not counted twice.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One row per savings account — an account's balance goes to DEAF once.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_deaf_remittance")
|
||||||
|
public class DeafRemittance {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "savings_account_id", nullable = false, unique = true)
|
||||||
|
private Long savingsAccountId;
|
||||||
|
|
||||||
|
@Column(name = "client_id", nullable = false)
|
||||||
|
private Long clientId;
|
||||||
|
|
||||||
|
/** The amount taken out of the account at escheat, which is what is owed to the fund. */
|
||||||
|
@Column(name = "amount", nullable = false, scale = 6, precision = 19)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/** The day the account was closed and the balance removed. */
|
||||||
|
@Column(name = "escheated_on", nullable = false)
|
||||||
|
private LocalDate escheatedOn;
|
||||||
|
|
||||||
|
/** The day the money was sent to RBI. */
|
||||||
|
@Column(name = "remitted_on", nullable = false)
|
||||||
|
private LocalDate remittedOn;
|
||||||
|
|
||||||
|
/** RBI's acknowledgement for the remittance, as printed on their advice. */
|
||||||
|
@Column(name = "deaf_reference", length = 100)
|
||||||
|
private String deafReference;
|
||||||
|
|
||||||
|
/** Set only if the customer later claimed the balance and it was recovered from the fund. */
|
||||||
|
@Column(name = "refunded_on")
|
||||||
|
private LocalDate refundedOn;
|
||||||
|
|
||||||
|
@Column(name = "remarks", length = 500)
|
||||||
|
private String remarks;
|
||||||
|
|
||||||
|
@Column(name = "recorded_by_user_id")
|
||||||
|
private Long recordedByUserId;
|
||||||
|
|
||||||
|
@Column(name = "recorded_on", nullable = false)
|
||||||
|
private LocalDateTime recordedOn;
|
||||||
|
|
||||||
|
protected DeafRemittance() {
|
||||||
|
// JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeafRemittance(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
|
||||||
|
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
|
||||||
|
final Long recordedByUserId) {
|
||||||
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.escheatedOn = escheatedOn;
|
||||||
|
this.remittedOn = remittedOn;
|
||||||
|
this.deafReference = deafReference;
|
||||||
|
this.remarks = remarks;
|
||||||
|
this.recordedByUserId = recordedByUserId;
|
||||||
|
this.recordedOn = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DeafRemittance record(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
|
||||||
|
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
|
||||||
|
final Long recordedByUserId) {
|
||||||
|
return new DeafRemittance(savingsAccountId, clientId, amount, escheatedOn, remittedOn, deafReference, remarks,
|
||||||
|
recordedByUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records that the customer claimed the balance back and it was recovered from the fund. */
|
||||||
|
public void markRefunded(final LocalDate refundedOn) {
|
||||||
|
this.refundedOn = refundedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSavingsAccountId() {
|
||||||
|
return savingsAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getClientId() {
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getAmount() {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getEscheatedOn() {
|
||||||
|
return escheatedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getRemittedOn() {
|
||||||
|
return remittedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeafReference() {
|
||||||
|
return deafReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getRefundedOn() {
|
||||||
|
return refundedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRemarks() {
|
||||||
|
return remarks;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface DeafRemittanceRepository extends JpaRepository<DeafRemittance, Long> {
|
||||||
|
|
||||||
|
Optional<DeafRemittance> findBySavingsAccountId(Long savingsAccountId);
|
||||||
|
|
||||||
|
boolean existsBySavingsAccountId(Long savingsAccountId);
|
||||||
|
|
||||||
|
List<DeafRemittance> findBySavingsAccountIdIn(List<Long> savingsAccountIds);
|
||||||
|
}
|
||||||
-3
@@ -68,9 +68,6 @@ public class FacilityConfigEditFields {
|
|||||||
@Column(name = "fc_inter_bank_total_cap_facility")
|
@Column(name = "fc_inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility;
|
private Boolean interBankTotalCapFacility;
|
||||||
|
|
||||||
@Column(name = "fc_withdrawal_facility")
|
|
||||||
private Boolean withdrawalFacility;
|
|
||||||
|
|
||||||
@Column(name = "fc_deposit_facility_kyc")
|
@Column(name = "fc_deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc;
|
private Boolean depositFacilityKyc;
|
||||||
|
|
||||||
|
|||||||
-2
@@ -417,7 +417,6 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility"));
|
f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility"));
|
||||||
f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility"));
|
f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility"));
|
||||||
f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility"));
|
f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility"));
|
||||||
f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility"));
|
|
||||||
f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc"));
|
f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc"));
|
||||||
f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard"));
|
f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard"));
|
||||||
return true;
|
return true;
|
||||||
@@ -441,7 +440,6 @@ public final class ProductPendingEditFieldMapper {
|
|||||||
config.addProperty("neftFacility", f.getNeftFacility());
|
config.addProperty("neftFacility", f.getNeftFacility());
|
||||||
config.addProperty("rtgsFacility", f.getRtgsFacility());
|
config.addProperty("rtgsFacility", f.getRtgsFacility());
|
||||||
config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility());
|
config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility());
|
||||||
config.addProperty("withdrawalFacility", f.getWithdrawalFacility());
|
|
||||||
config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc());
|
config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc());
|
||||||
config.addProperty("virtualDebitCard", f.getVirtualDebitCard());
|
config.addProperty("virtualDebitCard", f.getVirtualDebitCard());
|
||||||
root.add("accountFacilityConfig", config);
|
root.add("accountFacilityConfig", config);
|
||||||
|
|||||||
+73
-1
@@ -354,7 +354,13 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
|||||||
* realized, or a presented cheque not yet settled) -- kept as its own running total, separate from
|
* realized, or a presented cheque not yet settled) -- kept as its own running total, separate from
|
||||||
* {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures
|
* {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures
|
||||||
* rather than one bucket doing double duty. */
|
* rather than one bucket doing double duty. */
|
||||||
@Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true)
|
/*
|
||||||
|
* Initialised to zero rather than left null: the column is NOT NULL with a default of 0, and a
|
||||||
|
* default only applies when the column is omitted from the INSERT. JPA writes every mapped
|
||||||
|
* field, so a null here reaches the database as an explicit null and is rejected — which is what
|
||||||
|
* made every new savings account fail with a data-integrity error.
|
||||||
|
*/
|
||||||
|
@Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = false)
|
||||||
private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO;
|
private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO;
|
||||||
|
|
||||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
|
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
@@ -4206,6 +4212,72 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
|||||||
return this.sub_status;
|
return this.sub_status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the debit block that follows a lapsed periodic re-KYC.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Separate from {@link #blockDebits(Integer)} because that path is an officer's deliberate act
|
||||||
|
* and raises a validation error when the account is not active or is already blocked. This one
|
||||||
|
* runs unattended over the whole book, where those are ordinary conditions rather than mistakes
|
||||||
|
* — an already-blocked account simply needs nothing doing — so it reports whether it acted
|
||||||
|
* instead of throwing.
|
||||||
|
*
|
||||||
|
* @return true if the sub-status was changed
|
||||||
|
*/
|
||||||
|
public boolean applyKycExpiryDebitBlock() {
|
||||||
|
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
|
||||||
|
// A full block already stops debits, and a credit block plus a debit block is a full block.
|
||||||
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(current) ? SavingsAccountSubStatusEnum.BLOCK.getValue()
|
||||||
|
: SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the full block that follows a re-KYC left outstanding past the inoperative threshold.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The credits RBI requires be let through — benefit transfers, interest, the bank's own loan
|
||||||
|
* recoveries — are not covered by this flag and are posted through the paths that bypass the
|
||||||
|
* sub-status check, so blocking here does not hold them up.
|
||||||
|
*
|
||||||
|
* @return true if the sub-status was changed
|
||||||
|
*/
|
||||||
|
public boolean applyKycExpiryFullBlock() {
|
||||||
|
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts a block that a lapsed re-KYC put on, once the KYC has been renewed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only the states this ladder itself sets are cleared. A block an officer applied by hand, or
|
||||||
|
* one carrying a dormancy or escheat state, is left exactly as it is: this job put none of
|
||||||
|
* those on and has no business taking them off.
|
||||||
|
*
|
||||||
|
* @return true if the sub-status was changed
|
||||||
|
*/
|
||||||
|
public boolean releaseKycExpiryBlock() {
|
||||||
|
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
|
||||||
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) {
|
||||||
|
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public void validateForAccountBlock() {
|
public void validateForAccountBlock() {
|
||||||
final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus());
|
final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus());
|
||||||
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) {
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) {
|
||||||
|
|||||||
-7
@@ -75,9 +75,6 @@ public class SavingsAccountFacilityConfig 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;
|
||||||
|
|
||||||
@@ -138,9 +135,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
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; }
|
||||||
|
|
||||||
@@ -162,7 +156,6 @@ public class SavingsAccountFacilityConfig 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());
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -134,9 +134,6 @@ public class SavingsAccountPendingEdit 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;
|
||||||
|
|
||||||
|
|||||||
-2
@@ -371,8 +371,6 @@ public class SavingsProductAssembler {
|
|||||||
facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
||||||
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ?
|
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ?
|
||||||
facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
||||||
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ?
|
|
||||||
facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
|
|
||||||
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ?
|
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ?
|
||||||
facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
||||||
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ?
|
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ?
|
||||||
|
|||||||
-7
@@ -75,9 +75,6 @@ public class SavingsProductFacilityConfig 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;
|
||||||
|
|
||||||
@@ -138,9 +135,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
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; }
|
||||||
|
|
||||||
@@ -163,7 +157,6 @@ public class SavingsProductFacilityConfig 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();
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -81,7 +81,6 @@ public class FacilityChargeAutoApplyService {
|
|||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
|
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
|
||||||
|
|
||||||
@@ -305,7 +304,6 @@ public class FacilityChargeAutoApplyService {
|
|||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
|
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
|
||||||
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
|
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
|
||||||
if (!chargesToApply.isEmpty()) {
|
if (!chargesToApply.isEmpty()) {
|
||||||
|
|||||||
+89
-14
@@ -18,30 +18,43 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.service;
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import java.util.LinkedHashMap;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
|
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.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.context.annotation.DependsOn;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI,
|
* Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI,
|
||||||
* NEFT etc.), so the Facility Charge Mappings admin screen has ready-made charges to choose from.
|
* NEFT etc.), and points each facility's m_facility_charge_mapping row at its own default charge,
|
||||||
* Deliberately does NOT link a charge to its facility's m_facility_charge_mapping row or activate
|
* so the Facility Charge Mappings admin screen comes up correctly configured on any install rather
|
||||||
* it — that mapping must be an explicit admin choice made through that screen (an unmapped facility
|
* than showing an unmapped dash on every row.
|
||||||
* is blocked from being enabled on an account by FacilityChargeAutoApplyService). Seeder only
|
* <p>
|
||||||
* creates charges that are missing — safe to run repeatedly.
|
* Mappings are seeded INACTIVE on purpose. Activating a mapping is what actually starts charging
|
||||||
|
* the customer, so that stays an explicit admin decision made through that screen —
|
||||||
|
* FacilityChargeAutoApplyService treats an inactive mapping exactly like an unmapped one and blocks
|
||||||
|
* the facility from being enabled until an admin flips Active. Seeding the charge only removes the
|
||||||
|
* tedium of picking the obvious one; it does not pre-approve any billing.
|
||||||
|
* <p>
|
||||||
|
* Every step is guarded (charges created only when missing, mappings linked only while still
|
||||||
|
* unmapped), so an admin's own choice of charge or amount is never overwritten and the seeder is
|
||||||
|
* safe to run repeatedly and against existing databases.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class FacilityChargeSeederService {
|
@DependsOn({ "tenantDetailsService", "tenantDatabaseUpgradeService" })
|
||||||
|
public class FacilityChargeSeederService implements InitializingBean {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final TenantDetailsService tenantDetailsService;
|
||||||
|
|
||||||
// facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo]
|
// facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo]
|
||||||
// chargeTimeType: 3=SAVINGS_ACTIVATION, 6=ANNUAL_FEE, 7=MONTHLY_FEE
|
// chargeTimeType: 3=SAVINGS_ACTIVATION, 6=ANNUAL_FEE, 7=MONTHLY_FEE
|
||||||
@@ -63,13 +76,50 @@ public class FacilityChargeSeederService {
|
|||||||
FACILITY_DEFAULTS.put("NEFT_FACILITY", new Object[]{"NEFT Facility Charge", 5.00, 3, 2});
|
FACILITY_DEFAULTS.put("NEFT_FACILITY", new Object[]{"NEFT Facility Charge", 5.00, 3, 2});
|
||||||
FACILITY_DEFAULTS.put("RTGS_FACILITY", new Object[]{"RTGS Facility Charge", 25.00, 3, 2});
|
FACILITY_DEFAULTS.put("RTGS_FACILITY", new Object[]{"RTGS Facility Charge", 25.00, 3, 2});
|
||||||
FACILITY_DEFAULTS.put("INTER_BANK_TOTAL_CAP", new Object[]{"Inter-Bank Cap Charge", 0.00, 3, 2});
|
FACILITY_DEFAULTS.put("INTER_BANK_TOTAL_CAP", new Object[]{"Inter-Bank Cap Charge", 0.00, 3, 2});
|
||||||
FACILITY_DEFAULTS.put("WITHDRAWAL_FACILITY", new Object[]{"Withdrawal Facility Charge", 0.00, 3, 2});
|
|
||||||
FACILITY_DEFAULTS.put("DEPOSIT_FACILITY_KYC", new Object[]{"Deposit KYC Charge", 0.00, 3, 2});
|
FACILITY_DEFAULTS.put("DEPOSIT_FACILITY_KYC", new Object[]{"Deposit KYC Charge", 0.00, 3, 2});
|
||||||
FACILITY_DEFAULTS.put("VIRTUAL_DEBIT_CARD", new Object[]{"Virtual Debit Card Charge", 199.00, 3, 2});
|
FACILITY_DEFAULTS.put("VIRTUAL_DEBIT_CARD", new Object[]{"Virtual Debit Card Charge", 199.00, 3, 2});
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
/**
|
||||||
@Transactional
|
* Runs once per tenant after the tenant datasource and Liquibase migrations are ready.
|
||||||
|
* <p>
|
||||||
|
* This deliberately does NOT use {@code @PostConstruct}: that fires while the bean graph is still
|
||||||
|
* being built, before Fineract's per-tenant routing datasource is available, so every tenant table
|
||||||
|
* lookup failed with "bad SQL grammar" and the seeder silently skipped on every startup. Seeding
|
||||||
|
* has to happen after {@code tenantDatabaseUpgradeService} has migrated the schema, with the tenant
|
||||||
|
* bound to the thread so the routing datasource resolves to that tenant's database.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
final List<FineractPlatformTenant> tenants = tenantDetailsService.findAllTenants();
|
||||||
|
if (tenants == null || tenants.isEmpty()) {
|
||||||
|
log.warn("FacilityChargeSeeder: no tenants found, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final FineractPlatformTenant originalTenant = ThreadLocalContextUtil.getTenant();
|
||||||
|
try {
|
||||||
|
for (final FineractPlatformTenant tenant : tenants) {
|
||||||
|
try {
|
||||||
|
ThreadLocalContextUtil.setTenant(tenant);
|
||||||
|
seedFacilityCharges();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Seeding defaults must never take the platform down for one bad tenant.
|
||||||
|
log.warn("FacilityChargeSeeder: skipped for tenant '{}' — {}", tenant.getTenantIdentifier(),
|
||||||
|
e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ThreadLocalContextUtil.setTenant(originalTenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not {@code @Transactional}: this is invoked directly from {@link #afterPropertiesSet()} on the same
|
||||||
|
* bean, so Spring's proxy would be bypassed and the annotation would be a false promise. It needs no
|
||||||
|
* transaction anyway — every statement is individually guarded and idempotent, so a partial run simply
|
||||||
|
* completes on the next startup.
|
||||||
|
*/
|
||||||
public void seedFacilityCharges() {
|
public void seedFacilityCharges() {
|
||||||
try {
|
try {
|
||||||
for (Map.Entry<String, Object[]> entry : FACILITY_DEFAULTS.entrySet()) {
|
for (Map.Entry<String, Object[]> entry : FACILITY_DEFAULTS.entrySet()) {
|
||||||
@@ -107,8 +157,7 @@ public class FacilityChargeSeederService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every
|
// 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every
|
||||||
// facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge
|
// facility type. Inserted inactive and unmapped; step 3 below fills in the charge.
|
||||||
// for it via the Facility Charge Mappings screen before it can be enabled on an account.
|
|
||||||
List<Long> existingMapping = jdbcTemplate.queryForList(
|
List<Long> existingMapping = jdbcTemplate.queryForList(
|
||||||
"SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType);
|
"SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType);
|
||||||
|
|
||||||
@@ -121,11 +170,37 @@ public class FacilityChargeSeederService {
|
|||||||
|
|
||||||
log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType);
|
log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Point each still-unmapped facility at its own default charge (ATM_WITHDRAWAL ->
|
||||||
|
// "ATM Withdrawal Charge", ...) so a fresh install shows the correct charge on every row
|
||||||
|
// instead of a dash, on any machine, without an admin re-picking all 15 by hand.
|
||||||
|
//
|
||||||
|
// is_active is deliberately left false: activating the mapping is what actually starts
|
||||||
|
// billing the customer, and that must stay an explicit admin decision made through the
|
||||||
|
// Facility Charge Mappings screen (FacilityChargeAutoApplyService.requireMappedCharge
|
||||||
|
// treats an inactive mapping exactly like an unmapped one, so nothing is charged until
|
||||||
|
// an admin flips Active). This only removes the tedium of choosing the obvious charge,
|
||||||
|
// not the approval step.
|
||||||
|
//
|
||||||
|
// Scoped to charge_id IS NULL so a mapping or amount an admin has already set is never
|
||||||
|
// overwritten -- that makes this safe to re-run on every startup and on existing databases.
|
||||||
|
final int linked = jdbcTemplate.update(
|
||||||
|
"UPDATE m_facility_charge_mapping SET charge_id = "
|
||||||
|
+ "(SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id LIMIT 1) "
|
||||||
|
+ "WHERE facility_type = ? AND charge_id IS NULL",
|
||||||
|
chargeName, facilityType);
|
||||||
|
|
||||||
|
if (linked > 0) {
|
||||||
|
log.info("FacilityChargeSeeder: mapped facility '{}' to charge '{}' (left inactive"
|
||||||
|
+ " -- an admin must activate it before it applies)", facilityType, chargeName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("FacilityChargeSeeder: seeding complete.");
|
log.info("FacilityChargeSeeder: seeding complete.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage());
|
// Log the cause, not just the message: this swallowed a startup-ordering failure silently
|
||||||
|
// for a long time because the underlying SQLException never reached the log.
|
||||||
|
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -54,4 +54,5 @@
|
|||||||
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
|
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
|
||||||
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
|
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
|
||||||
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
|
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
|
||||||
|
<include file="parts/2032_remove_withdrawal_facility.xml" relativeToChangelogFile="true" />
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+17
@@ -188,4 +188,21 @@
|
|||||||
);
|
);
|
||||||
</sql>
|
</sql>
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="2005-8-savings-product-account-type-add-normal-saving-account">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_code (code_name, is_system_defined)
|
||||||
|
SELECT 'SavingsProductAccountType', TRUE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductAccountType');
|
||||||
|
|
||||||
|
INSERT INTO m_code_value (code_id, code_value, order_position, code_description, is_active, is_mandatory)
|
||||||
|
SELECT c.id, 'Normal Saving Account', 4, 'Normal Saving Account', TRUE, FALSE
|
||||||
|
FROM m_code c
|
||||||
|
WHERE c.code_name = 'SavingsProductAccountType'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_code_value cv
|
||||||
|
WHERE cv.code_id = c.id AND cv.code_value = 'Normal Saving Account'
|
||||||
|
);
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Retires the "Withdrawal Facility" account feature and its auto-applied charge.
|
||||||
|
|
||||||
|
The facility flag is gone from the product/account APIs and UI, so the charge can no
|
||||||
|
longer be switched on for anything new. This changeset stops it billing going forward
|
||||||
|
and hides it from charge pickers, while leaving already-posted transactions intact so
|
||||||
|
historical statements and the trial balance still reconcile.
|
||||||
|
|
||||||
|
The withdrawal_facility / fc_withdrawal_facility columns are deliberately NOT dropped:
|
||||||
|
they are simply no longer mapped or read. Dropping them would be irreversible and buys
|
||||||
|
nothing, and the historical changesets that created them stay untouched so Liquibase
|
||||||
|
checksums keep validating.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Step 1: unmap the facility type so the seeder/auto-apply can never resolve a charge -->
|
||||||
|
<changeSet id="2032-1" author="cbs-keshav">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_facility_charge_mapping" />
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
DELETE FROM m_facility_charge_mapping WHERE facility_type = 'WITHDRAWAL_FACILITY';
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Step 2: drop pending (unpaid, unwaived) withdrawal-facility charges from savings accounts.
|
||||||
|
Paid or waived rows are left alone so posted accounting entries stay balanced. -->
|
||||||
|
<changeSet id="2032-2" author="cbs-keshav">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_savings_account_charge" />
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
DELETE FROM m_savings_account_charge
|
||||||
|
WHERE charge_id IN (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge')
|
||||||
|
AND COALESCE(is_paid_derived, false) = false
|
||||||
|
AND COALESCE(waived, false) = false;
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Step 3: soft-delete + deactivate the charge definition so it disappears from
|
||||||
|
charge dropdowns without breaking FKs from any historical charge rows. -->
|
||||||
|
<changeSet id="2032-3" author="cbs-keshav">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = false</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_charge SET is_active = false, is_deleted = true
|
||||||
|
WHERE name = 'Withdrawal Facility Charge';
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Step 4: stop the flag driving anything if a stale row is still set -->
|
||||||
|
<changeSet id="2032-4" author="cbs-keshav">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_savings_account_facility_config" />
|
||||||
|
<columnExists tableName="m_savings_account_facility_config" columnName="withdrawal_facility" />
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_savings_account_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="2032-5" author="cbs-keshav">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_savings_product_facility_config" />
|
||||||
|
<columnExists tableName="m_savings_product_facility_config" columnName="withdrawal_facility" />
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_savings_product_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user